plutonium 0.60.5 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +70 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +122 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +945 -286
- metadata +113 -2
|
@@ -33,6 +33,59 @@ module Plutonium
|
|
|
33
33
|
# filtering). Produced by #resolve_form_sections (a later task).
|
|
34
34
|
ResolvedSection = Struct.new(:section, :fields)
|
|
35
35
|
|
|
36
|
+
# First-section-wins ownership, shared by every layout resolver (the resource
|
|
37
|
+
# form via {#resolve_form_sections}, and the wizard's {StepAdapter} /
|
|
38
|
+
# {FieldImporter}). Each field is claimed by the FIRST explicit section that
|
|
39
|
+
# lists it, restricted to the currently-permitted `fields`; a listed field
|
|
40
|
+
# outside that set is simply skipped (never renders, never an error).
|
|
41
|
+
#
|
|
42
|
+
# @param layout [Array<Section>]
|
|
43
|
+
# @param fields [Array<Symbol,String>] the permitted field set, in order
|
|
44
|
+
# @return [Array(Hash, Array<Symbol>)] `[owner_by_field, leftover_fields]`
|
|
45
|
+
def self.assign_ownership(layout, fields)
|
|
46
|
+
fields = fields.map(&:to_sym)
|
|
47
|
+
known = fields.to_set
|
|
48
|
+
owner = {}
|
|
49
|
+
layout.each do |section|
|
|
50
|
+
next if section.ungrouped?
|
|
51
|
+
section.fields.map(&:to_sym).each { |f| owner[f] ||= section.key if known.include?(f) }
|
|
52
|
+
end
|
|
53
|
+
leftovers = fields.reject { |f| owner.key?(f) }
|
|
54
|
+
[owner, leftovers]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The canonical resolution: first-section-wins ownership, with unclaimed
|
|
58
|
+
# permitted fields falling into the (explicit, or a synthesized trailing)
|
|
59
|
+
# ungrouped bucket so nothing silently disappears. Every declared section is
|
|
60
|
+
# kept (empty ones are dropped at render). Shared by {#resolve_form_sections}
|
|
61
|
+
# and the wizard {StepAdapter}'s raw-layout path so the two never drift.
|
|
62
|
+
#
|
|
63
|
+
# @param layout [Array<Section>]
|
|
64
|
+
# @param fields [Array<Symbol,String>] the permitted field set, in order
|
|
65
|
+
# @return [Array<ResolvedSection>]
|
|
66
|
+
def self.resolve_sections(layout, fields)
|
|
67
|
+
owner, leftovers = assign_ownership(layout, fields)
|
|
68
|
+
|
|
69
|
+
resolved = layout.map do |section|
|
|
70
|
+
section_fields =
|
|
71
|
+
if section.ungrouped?
|
|
72
|
+
leftovers
|
|
73
|
+
else
|
|
74
|
+
section.fields.map(&:to_sym).select { |f| owner[f] == section.key }
|
|
75
|
+
end
|
|
76
|
+
ResolvedSection.new(section:, fields: section_fields)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
unless layout.any?(&:ungrouped?)
|
|
80
|
+
resolved.push(ResolvedSection.new(
|
|
81
|
+
section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
|
|
82
|
+
fields: leftovers
|
|
83
|
+
))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
resolved
|
|
87
|
+
end
|
|
88
|
+
|
|
36
89
|
# Collects section/ungrouped calls from a form_layout block in order.
|
|
37
90
|
class Builder
|
|
38
91
|
attr_reader :sections
|
|
@@ -102,41 +155,7 @@ module Plutonium
|
|
|
102
155
|
layout = defined_form_layout
|
|
103
156
|
return nil unless layout
|
|
104
157
|
|
|
105
|
-
|
|
106
|
-
known = resource_fields.to_set
|
|
107
|
-
|
|
108
|
-
# First-section-wins assignment: each field is claimed by the first
|
|
109
|
-
# section that lists it. A field a section lists but that isn't in the
|
|
110
|
-
# currently-permitted set (policy, per-action, entity scoping, nesting)
|
|
111
|
-
# is simply skipped — it never renders and is never an error.
|
|
112
|
-
owner = {}
|
|
113
|
-
layout.each do |section|
|
|
114
|
-
next if section.ungrouped?
|
|
115
|
-
section.fields.each do |f|
|
|
116
|
-
owner[f] ||= section.key if known.include?(f)
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
leftovers = resource_fields.reject { |f| owner.key?(f) }
|
|
120
|
-
|
|
121
|
-
resolved = layout.map do |section|
|
|
122
|
-
fields =
|
|
123
|
-
if section.ungrouped?
|
|
124
|
-
leftovers
|
|
125
|
-
else
|
|
126
|
-
section.fields.select { |f| owner[f] == section.key }
|
|
127
|
-
end
|
|
128
|
-
ResolvedSection.new(section:, fields:)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
unless layout.any?(&:ungrouped?)
|
|
132
|
-
implicit = ResolvedSection.new(
|
|
133
|
-
section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
|
|
134
|
-
fields: leftovers
|
|
135
|
-
)
|
|
136
|
-
resolved.push(implicit)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
resolved
|
|
158
|
+
FormLayout.resolve_sections(layout, resource_fields)
|
|
140
159
|
end
|
|
141
160
|
end
|
|
142
161
|
end
|
|
@@ -18,7 +18,7 @@ module Plutonium
|
|
|
18
18
|
module IndexViews
|
|
19
19
|
extend ActiveSupport::Concern
|
|
20
20
|
|
|
21
|
-
KNOWN_VIEWS = %i[table grid].freeze
|
|
21
|
+
KNOWN_VIEWS = %i[table grid kanban].freeze
|
|
22
22
|
GRID_SLOTS = %i[image header subheader body meta footer].freeze
|
|
23
23
|
GRID_LAYOUTS = %i[compact media].freeze
|
|
24
24
|
|
|
@@ -28,6 +28,8 @@ module Plutonium
|
|
|
28
28
|
class_attribute :defined_grid_fields, default: {}, instance_accessor: false
|
|
29
29
|
class_attribute :defined_grid_layout, default: :compact, instance_accessor: false
|
|
30
30
|
class_attribute :defined_grid_columns, default: nil, instance_accessor: false
|
|
31
|
+
class_attribute :defined_kanban_block, default: nil, instance_accessor: false
|
|
32
|
+
class_attribute :defined_kanban_board, default: nil, instance_accessor: false
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
class_methods do
|
|
@@ -83,6 +85,71 @@ module Plutonium
|
|
|
83
85
|
def grid_columns(value)
|
|
84
86
|
self.defined_grid_columns = Integer(value)
|
|
85
87
|
end
|
|
88
|
+
|
|
89
|
+
# Declares a kanban board for this resource and enables the :kanban
|
|
90
|
+
# index view (mirrors how grid_fields enables :grid). The block is the
|
|
91
|
+
# kanban DSL, compiled lazily into a Plutonium::Kanban::Board later.
|
|
92
|
+
#
|
|
93
|
+
# ## Column action auto-registration
|
|
94
|
+
#
|
|
95
|
+
# Each column action declared inside the block is automatically registered
|
|
96
|
+
# as an interactive resource action (via `action name, interaction:`) so
|
|
97
|
+
# the existing bulk_actions/:key route resolves and
|
|
98
|
+
# `interactive_resource_actions` look-up succeeds at request time.
|
|
99
|
+
#
|
|
100
|
+
# Only STATIC columns (declared with `column :key …`) can be introspected
|
|
101
|
+
# at class-load time. Dynamic boards (`columns do … end`) must declare
|
|
102
|
+
# any column-action interactions as top-level definition `action` calls
|
|
103
|
+
# separately (the constraint is structural: the block is only evaluated at
|
|
104
|
+
# request time with a live context object, so its columns are unknown here).
|
|
105
|
+
def kanban(&block)
|
|
106
|
+
self.defined_kanban_block = block
|
|
107
|
+
self.defined_index_views = defined_index_views + [:kanban] unless defined_index_views.include?(:kanban)
|
|
108
|
+
|
|
109
|
+
# Eagerly compile the board to extract static column actions and
|
|
110
|
+
# register each one as an interactive resource action.
|
|
111
|
+
#
|
|
112
|
+
# Safety of compiling at class-load time:
|
|
113
|
+
# * The board DSL never accesses the database.
|
|
114
|
+
# * BUT interaction constants referenced in column action blocks
|
|
115
|
+
# (e.g. `interaction: ArchiveTasksInteraction`) ARE resolved here,
|
|
116
|
+
# at definition class-load time. They must therefore be autoloadable
|
|
117
|
+
# WITHOUT a circular dependency back on this definition class — an
|
|
118
|
+
# interaction that references the definition at its own load time
|
|
119
|
+
# would deadlock the autoloader. In practice interactions depend only
|
|
120
|
+
# on their model, so this constraint is naturally satisfied.
|
|
121
|
+
board = Plutonium::Kanban::DSL.build(&block)
|
|
122
|
+
# Cache the compiled board so the controller can reuse it instead of
|
|
123
|
+
# recompiling per request (see KanbanActions#current_kanban_board).
|
|
124
|
+
self.defined_kanban_board = board
|
|
125
|
+
board.columns.each do |col|
|
|
126
|
+
col.actions.each do |col_action|
|
|
127
|
+
action(
|
|
128
|
+
col_action.key,
|
|
129
|
+
interaction: col_action.interaction,
|
|
130
|
+
label: col_action.label,
|
|
131
|
+
icon: col_action.icon,
|
|
132
|
+
confirmation: col_action.confirmation
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Register each column's enter_interaction as an interactive record
|
|
137
|
+
# action too, so its form and params extraction exist and route the
|
|
138
|
+
# standard way. It is flagged `kanban_drop: true` so it is excluded
|
|
139
|
+
# from the normal show/row/index toolbars — it is reachable only by
|
|
140
|
+
# dropping a card. The key is column-scoped (enter_interaction_key →
|
|
141
|
+
# :<column>_enter_interaction), so it is unique by construction and
|
|
142
|
+
# never collides with another column's interaction. It carries NO
|
|
143
|
+
# policy method of its own — the drop is authorized by kanban_move?.
|
|
144
|
+
if col.enter_interaction?
|
|
145
|
+
action(
|
|
146
|
+
col.enter_interaction_key,
|
|
147
|
+
interaction: col.enter_interaction,
|
|
148
|
+
kanban_drop: true
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
86
153
|
end
|
|
87
154
|
|
|
88
155
|
def defined_index_views = self.class.defined_index_views
|
|
@@ -90,6 +157,8 @@ module Plutonium
|
|
|
90
157
|
def defined_grid_fields = self.class.defined_grid_fields
|
|
91
158
|
def defined_grid_layout = self.class.defined_grid_layout
|
|
92
159
|
def defined_grid_columns = self.class.defined_grid_columns
|
|
160
|
+
def defined_kanban_block = self.class.defined_kanban_block
|
|
161
|
+
def defined_kanban_board = self.class.defined_kanban_board
|
|
93
162
|
end
|
|
94
163
|
end
|
|
95
164
|
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# The `wizard` definition macro (§5.1) — sugar over the Action system, mirroring
|
|
6
|
+
# {Plutonium::Definition::Actions}. It registers a launching action for a
|
|
7
|
+
# wizard auto-mounted on the resource's own controller (see
|
|
8
|
+
# {Plutonium::Resource::Controllers::WizardActions}):
|
|
9
|
+
#
|
|
10
|
+
# class CompanyDefinition < Plutonium::Resource::Definition
|
|
11
|
+
# wizard :configure, ConfigureCompanyWizard # anchored → record action (show + list)
|
|
12
|
+
# wizard :configure, ConfigureCompanyWizard, collection_record_action: false # show page only
|
|
13
|
+
# wizard :onboard, CompanyOnboardingWizard # no anchor → resource action
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Placement is dictated by the wizard, mirroring interactions: an **anchored**
|
|
17
|
+
# wizard is a **record** action (the anchor is the URL `:id`, resolved through the
|
|
18
|
+
# resource controller's scoped, policy-gated `resource_record!`); a **non-anchored**
|
|
19
|
+
# wizard is a **resource** (collection) action. It's not overridable — a flag that
|
|
20
|
+
# doesn't apply to the kind raises. The configurable surface is where a RECORD
|
|
21
|
+
# action shows: the show page (`record_action:`) and the list rows
|
|
22
|
+
# (`collection_record_action:`), both on by default. Bulk wizards are not
|
|
23
|
+
# supported (§5.1) — wizards are per-instance flows.
|
|
24
|
+
#
|
|
25
|
+
# The macro keeps a per-definition registry (`registered_wizards`) the
|
|
26
|
+
# resource-mounted {WizardActions} concern reads to resolve the wizard class by
|
|
27
|
+
# the `:wizard_name` route segment, and synthesizes a launch action whose URL
|
|
28
|
+
# resolver targets the auto-mounted member (anchored) or collection routes.
|
|
29
|
+
module Wizards
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
# @return [Hash{Symbol=>Hash}] registry of wizards declared on this
|
|
34
|
+
# definition: `{name => {wizard_class:, record_action:}}`. Read by
|
|
35
|
+
# {Plutonium::Resource::Controllers::WizardActions} to resolve + gate.
|
|
36
|
+
def registered_wizards
|
|
37
|
+
@registered_wizards ||= {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Definitions are inheritable; carry the wizard registry to subclasses.
|
|
41
|
+
def inherited(subclass)
|
|
42
|
+
super
|
|
43
|
+
subclass.instance_variable_set(:@registered_wizards, registered_wizards.dup)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Placement is dictated by the wizard, not chosen: an **anchored** wizard is
|
|
47
|
+
# a **record** action (it needs a record — the anchor), a **non-anchored**
|
|
48
|
+
# wizard is a **resource** (collection) action. The only configurable surface
|
|
49
|
+
# is WHERE a *record* action shows — the **show** page (`record_action:`) and
|
|
50
|
+
# the **list** rows (`collection_record_action:`), both on by default. Flags
|
|
51
|
+
# that don't apply to the wizard's kind are rejected.
|
|
52
|
+
#
|
|
53
|
+
# Like an interaction, a `wizard` action is gated by a policy predicate named
|
|
54
|
+
# after its key — `def configure? = update?` for `wizard :configure, …`. The
|
|
55
|
+
# SAME predicate drives both the launch action's visibility
|
|
56
|
+
# (`Action#permitted_by?` on index/show) and its authorization
|
|
57
|
+
# ({WizardActions#authorize_wizard_*_action!}), so the button and the action
|
|
58
|
+
# stay in lockstep. A missing predicate raises `ActionPolicy::UnknownRule`
|
|
59
|
+
# (exactly as a missing interaction predicate does) — define it.
|
|
60
|
+
#
|
|
61
|
+
# @param name [Symbol] the action key (e.g. :configure)
|
|
62
|
+
# @param wizard_class [Class] a Plutonium::Wizard::Base subclass
|
|
63
|
+
# @param opts [Hash] action overrides — chrome (`label:`/`icon:`/`position:`/
|
|
64
|
+
# `category:`/`confirmation:`/`turbo_frame:`) plus, for a RECORD wizard, the
|
|
65
|
+
# show/list surface flags `record_action:`/`collection_record_action:`.
|
|
66
|
+
def wizard(name, wizard_class, **opts)
|
|
67
|
+
is_record = wizard_class.anchored?
|
|
68
|
+
reject_inapplicable_surface!(name, wizard_class, is_record, opts)
|
|
69
|
+
|
|
70
|
+
registered_wizards[name.to_sym] = {wizard_class:, record_action: is_record}
|
|
71
|
+
|
|
72
|
+
resolver = wizard_launch_resolver(name, is_record)
|
|
73
|
+
|
|
74
|
+
# A record (anchored) wizard surfaces on BOTH the show page (`record_action`)
|
|
75
|
+
# AND each list row (`collection_record_action`, scoped to that row's
|
|
76
|
+
# record); a resource (non-anchored) wizard is the collection-level
|
|
77
|
+
# `resource_action`. `opts` are spliced AFTER, so a record wizard can opt out
|
|
78
|
+
# of either surface (e.g. `collection_record_action: false` → show page only).
|
|
79
|
+
action(
|
|
80
|
+
name,
|
|
81
|
+
route_options: Plutonium::Action::RouteOptions.new(
|
|
82
|
+
method: :get, url_resolver: resolver
|
|
83
|
+
),
|
|
84
|
+
label: wizard_label(wizard_class, name),
|
|
85
|
+
icon: wizard_icon(wizard_class),
|
|
86
|
+
description: wizard_class.description,
|
|
87
|
+
category: :primary,
|
|
88
|
+
record_action: is_record,
|
|
89
|
+
collection_record_action: is_record,
|
|
90
|
+
resource_action: !is_record,
|
|
91
|
+
**opts.except(:condition),
|
|
92
|
+
condition: wizard_launch_condition(wizard_class, opts[:condition])
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Reject surface flags that don't apply to the wizard's kind. Placement is
|
|
99
|
+
# fixed by `anchored?`: a RECORD (anchored) wizard can only be placed on the
|
|
100
|
+
# show page / list rows (`record_action:`/`collection_record_action:`) — it
|
|
101
|
+
# can't be a `resource_action` (there's no record at the collection level); a
|
|
102
|
+
# RESOURCE (non-anchored) wizard is a collection-level action with no record,
|
|
103
|
+
# so the record/list surfaces don't apply.
|
|
104
|
+
def reject_inapplicable_surface!(name, wizard_class, is_record, opts)
|
|
105
|
+
disallowed =
|
|
106
|
+
if is_record
|
|
107
|
+
[:resource_action]
|
|
108
|
+
else
|
|
109
|
+
[:record_action, :collection_record_action]
|
|
110
|
+
end
|
|
111
|
+
bad = disallowed.select { |flag| opts.key?(flag) }
|
|
112
|
+
return if bad.empty?
|
|
113
|
+
|
|
114
|
+
kind = is_record ? "anchored (a record action)" : "not anchored (a resource action)"
|
|
115
|
+
applies = is_record ? "record_action: / collection_record_action: (show page / list rows)" : "none (it's the collection-level action)"
|
|
116
|
+
raise ArgumentError,
|
|
117
|
+
"wizard :#{name} — #{wizard_class} is #{kind}; " \
|
|
118
|
+
"#{bad.join(", ")} #{(bad.size == 1) ? "doesn't" : "don't"} apply. " \
|
|
119
|
+
"Configurable surface here: #{applies}."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The launch action's `condition:` (§9). A **one-time** wizard's launch is
|
|
123
|
+
# hidden once the current user has already completed it: the condition
|
|
124
|
+
# recomputes the wizard's `instance_key` for the current context (same
|
|
125
|
+
# {Plutonium::Wizard.compute_instance_key} the driving layer + gate use) and
|
|
126
|
+
# returns false when a retained `completed` row exists at that key. A
|
|
127
|
+
# repeatable (non-one-time) wizard gets NO completion condition.
|
|
128
|
+
#
|
|
129
|
+
# When the author also passed a `condition:`, the two are AND-ed: the action
|
|
130
|
+
# shows only if the author's condition is met AND the wizard isn't already
|
|
131
|
+
# completed.
|
|
132
|
+
#
|
|
133
|
+
# The proc runs in a {Plutonium::Action::ConditionContext}: `object`/`record`
|
|
134
|
+
# is the anchor for a record action (nil for a resource action), view helpers
|
|
135
|
+
# (`current_user`) delegate to the view context, and `current_scoped_entity` /
|
|
136
|
+
# `scoped_to_entity?` are read off the host controller (`controller`) exactly
|
|
137
|
+
# as the gate recomputes them.
|
|
138
|
+
def wizard_launch_condition(wizard_class, author_condition)
|
|
139
|
+
return author_condition unless wizard_class.one_time?
|
|
140
|
+
|
|
141
|
+
completion_condition = proc do
|
|
142
|
+
# This runs on EVERY index/show render. The `wizard` macro is not gated
|
|
143
|
+
# on `config.wizards.enabled` (only routing is), so guard the DB query:
|
|
144
|
+
# when the subsystem is disabled its routes aren't drawn (a launch button
|
|
145
|
+
# would 404) → hide the action; when it's enabled but the sessions table
|
|
146
|
+
# hasn't been migrated yet, treat the wizard as not-yet-completed (show)
|
|
147
|
+
# rather than raising StatementInvalid mid-render.
|
|
148
|
+
next false unless Plutonium.configuration.wizards.enabled
|
|
149
|
+
next true unless Plutonium::Wizard::Session.table_exists?
|
|
150
|
+
|
|
151
|
+
scope =
|
|
152
|
+
if controller.scoped_to_entity?
|
|
153
|
+
controller.current_scoped_entity
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
instance_key = Plutonium::Wizard.compute_instance_key(
|
|
157
|
+
wizard_class: wizard_class,
|
|
158
|
+
current_user: current_user,
|
|
159
|
+
current_scoped_entity: scope,
|
|
160
|
+
anchor: wizard_class.anchored? ? object : nil,
|
|
161
|
+
wizard_token: nil
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
!Plutonium::Wizard::Store::ActiveRecord.new.completed?(instance_key: instance_key)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return completion_condition if author_condition.nil?
|
|
168
|
+
|
|
169
|
+
# AND the author's condition with the completion check; both run in the
|
|
170
|
+
# same ConditionContext, so evaluate each via instance_exec on self.
|
|
171
|
+
proc do
|
|
172
|
+
instance_exec(&author_condition) && instance_exec(&completion_condition)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def wizard_label(wizard_class, name)
|
|
177
|
+
if wizard_class.respond_to?(:label) && wizard_class.label.present?
|
|
178
|
+
wizard_class.label
|
|
179
|
+
else
|
|
180
|
+
name.to_s.humanize
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def wizard_icon(wizard_class)
|
|
185
|
+
wizard_class.icon || Phlex::TablerIcons::Wand
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A url_resolver proc (§5.1). Evaluated against the controller with the
|
|
189
|
+
# subject; builds the wizard's bare LAUNCH URL on the auto-mounted resource
|
|
190
|
+
# route — the member route for an anchored wizard (id from the subject), the
|
|
191
|
+
# collection route otherwise. The launch action resolves the run and
|
|
192
|
+
# redirects to its current step (the resumed cursor for an in-progress keyed
|
|
193
|
+
# run, else the first step), with the token already in the URL — so we never
|
|
194
|
+
# hardcode a step here.
|
|
195
|
+
def wizard_launch_resolver(name, is_record)
|
|
196
|
+
wizard_name = name.to_s
|
|
197
|
+
|
|
198
|
+
proc do |subject|
|
|
199
|
+
if is_record
|
|
200
|
+
resource_url_for(subject, wizard: wizard_name)
|
|
201
|
+
else
|
|
202
|
+
resource_url_for(resource_class, wizard: wizard_name)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -40,6 +40,7 @@ module Plutonium
|
|
|
40
40
|
enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
|
|
41
41
|
|
|
42
42
|
# Callbacks
|
|
43
|
+
before_validation :normalize_email
|
|
43
44
|
before_validation :set_token_defaults, on: :create
|
|
44
45
|
after_commit :send_invitation_email, on: :create
|
|
45
46
|
|
|
@@ -166,6 +167,14 @@ module Plutonium
|
|
|
166
167
|
email.split("@").last&.downcase
|
|
167
168
|
end
|
|
168
169
|
|
|
170
|
+
# Normalize the login to lowercase so it agrees with case-insensitive
|
|
171
|
+
# lookups (account_from_login, find_by(email:)) on a case-sensitive DB.
|
|
172
|
+
# This is the single source of truth: every creation path stores a
|
|
173
|
+
# normalized email regardless of how the record was built.
|
|
174
|
+
def normalize_email
|
|
175
|
+
self.email = email.downcase if email.present?
|
|
176
|
+
end
|
|
177
|
+
|
|
169
178
|
def set_token_defaults
|
|
170
179
|
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
171
180
|
self.expires_at ||= 1.week.from_now
|
|
@@ -29,6 +29,15 @@ module Plutonium
|
|
|
29
29
|
attribute :resource
|
|
30
30
|
attribute :email
|
|
31
31
|
|
|
32
|
+
# Normalize the login to lowercase so the dedup guards
|
|
33
|
+
# (user_not_already_member, no_pending_invitation) and the created
|
|
34
|
+
# invite all agree on case. Lookups elsewhere downcase, and the DB may
|
|
35
|
+
# be case-sensitive. Defined on the class so it overrides the
|
|
36
|
+
# ActiveModel attribute reader; super reaches the stored value.
|
|
37
|
+
def email
|
|
38
|
+
super&.downcase
|
|
39
|
+
end
|
|
40
|
+
|
|
32
41
|
validates :email, presence: true
|
|
33
42
|
validate :role_is_present
|
|
34
43
|
validate :user_not_already_member
|
|
@@ -123,7 +123,10 @@ module Plutonium
|
|
|
123
123
|
|
|
124
124
|
# Handle the signup form submission.
|
|
125
125
|
def handle_signup_submission
|
|
126
|
-
|
|
126
|
+
# Normalize the login up front so the existing-account guard below and
|
|
127
|
+
# the account it creates agree on case with case-insensitive lookups
|
|
128
|
+
# (account_from_login) on a case-sensitive DB.
|
|
129
|
+
email = (@invite.enforce_email? ? @invite.email : params[:email])&.downcase
|
|
127
130
|
password = params[:password]
|
|
128
131
|
password_confirmation = params[:password_confirmation]
|
|
129
132
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Kanban
|
|
5
|
+
class Board
|
|
6
|
+
attr_reader :columns, :columns_block, :card_fields, :per_column, :position_config, :show_in
|
|
7
|
+
|
|
8
|
+
# nil means "inherit the definition's show_in"; the board only overrides
|
|
9
|
+
# when explicitly set to :modal or :page.
|
|
10
|
+
VALID_SHOW_IN = [nil, :modal, :page].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(columns:, columns_block:, card_fields:, per_column:, realtime:, position_config:, lazy:, show_in: nil)
|
|
13
|
+
unless VALID_SHOW_IN.include?(show_in)
|
|
14
|
+
raise ArgumentError, "show_in must be one of #{VALID_SHOW_IN.compact.inspect} (or unset), got #{show_in.inspect}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@columns = columns
|
|
18
|
+
@columns_block = columns_block
|
|
19
|
+
@card_fields = card_fields
|
|
20
|
+
@per_column = per_column
|
|
21
|
+
@realtime = realtime
|
|
22
|
+
@position_config = position_config
|
|
23
|
+
@lazy = lazy
|
|
24
|
+
@show_in = show_in
|
|
25
|
+
@columns.each(&:freeze)
|
|
26
|
+
@columns.freeze
|
|
27
|
+
@card_fields&.freeze
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def realtime? = !!@realtime
|
|
32
|
+
def lazy? = !!@lazy
|
|
33
|
+
def dynamic? = !@columns_block.nil?
|
|
34
|
+
|
|
35
|
+
# Resolves the board's effective show_in, falling back to the definition's
|
|
36
|
+
# `show_in` when the board doesn't override it. Pass the resource definition.
|
|
37
|
+
def show_in_for(definition) = @show_in || definition.show_in
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Kanban
|
|
5
|
+
# Builds ActionCable stream names and broadcasts Turbo Stream updates for
|
|
6
|
+
# opt-in realtime kanban boards.
|
|
7
|
+
#
|
|
8
|
+
# ## Tenant isolation
|
|
9
|
+
#
|
|
10
|
+
# The stream name is a three-segment string:
|
|
11
|
+
#
|
|
12
|
+
# "kanban:<tenant>:<resource>"
|
|
13
|
+
#
|
|
14
|
+
# where <tenant> is the scoped entity's GID param (e.g. "Z2lkOi8vYXBwL09yZy8x")
|
|
15
|
+
# or the literal string "global" for unscoped portals.
|
|
16
|
+
#
|
|
17
|
+
# Two viewers are on the SAME stream only when they share identical
|
|
18
|
+
# resource_class AND scoped_entity — different tenants can never share a
|
|
19
|
+
# stream because their GID params are distinct by definition.
|
|
20
|
+
module Broadcaster
|
|
21
|
+
extend self
|
|
22
|
+
|
|
23
|
+
# Returns the streamables array that identifies this board's ActionCable stream.
|
|
24
|
+
#
|
|
25
|
+
# Pass the returned array directly to turbo-rails helpers:
|
|
26
|
+
#
|
|
27
|
+
# turbo_stream_from(*Broadcaster.stream_name(resource_class: Task, scoped_entity: org))
|
|
28
|
+
# Turbo::StreamsChannel.broadcast_stream_to(*stream_name, content:)
|
|
29
|
+
#
|
|
30
|
+
# @param resource_class [Class] the ActiveRecord model class for the board
|
|
31
|
+
# @param scoped_entity [ActiveRecord::Base, nil] the tenant record, or nil
|
|
32
|
+
# for portals that are not entity-scoped
|
|
33
|
+
# @return [Array<String>]
|
|
34
|
+
def stream_name(resource_class:, scoped_entity:)
|
|
35
|
+
entity_segment = scoped_entity&.to_gid_param || "global"
|
|
36
|
+
["kanban", entity_segment, resource_class.name]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Broadcasts the Turbo Stream HTML to all ActionCable subscribers watching
|
|
40
|
+
# this board's stream.
|
|
41
|
+
#
|
|
42
|
+
# @param resource_class [Class]
|
|
43
|
+
# @param scoped_entity [ActiveRecord::Base, nil]
|
|
44
|
+
# @param content [String] the raw turbo-stream HTML (one or more
|
|
45
|
+
# <turbo-stream> tags) to push to subscribers
|
|
46
|
+
def broadcast(resource_class:, scoped_entity:, content:)
|
|
47
|
+
Turbo::StreamsChannel.broadcast_stream_to(
|
|
48
|
+
*stream_name(resource_class:, scoped_entity:),
|
|
49
|
+
content: content
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|