plutonium 0.60.5 → 0.61.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 +313 -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 +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- 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/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -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 +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -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/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/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/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -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 +54 -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 +69 -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/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- 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 +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- 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/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- 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/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -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 +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -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 +271 -282
- metadata +100 -1
|
@@ -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,55 @@ 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
|
+
end
|
|
136
|
+
end
|
|
86
137
|
end
|
|
87
138
|
|
|
88
139
|
def defined_index_views = self.class.defined_index_views
|
|
@@ -90,6 +141,8 @@ module Plutonium
|
|
|
90
141
|
def defined_grid_fields = self.class.defined_grid_fields
|
|
91
142
|
def defined_grid_layout = self.class.defined_grid_layout
|
|
92
143
|
def defined_grid_columns = self.class.defined_grid_columns
|
|
144
|
+
def defined_kanban_block = self.class.defined_kanban_block
|
|
145
|
+
def defined_kanban_board = self.class.defined_kanban_board
|
|
93
146
|
end
|
|
94
147
|
end
|
|
95
148
|
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
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Kanban
|
|
5
|
+
class Column
|
|
6
|
+
ROLE_PRESETS = {
|
|
7
|
+
backlog: {add: true},
|
|
8
|
+
done: {color: :green, collapsed: true}
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :key, :label, :color, :wip, :scope, :on_drop, :accepts, :actions
|
|
12
|
+
|
|
13
|
+
def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_drop: nil,
|
|
14
|
+
collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil)
|
|
15
|
+
preset = role ? ROLE_PRESETS.fetch(role) { raise ArgumentError, "Unknown column role: #{role.inspect}. Valid: #{ROLE_PRESETS.keys.inspect}" } : {}
|
|
16
|
+
@key = key.to_sym
|
|
17
|
+
@label = label || key.to_s.titleize
|
|
18
|
+
@color = color.nil? ? preset[:color] : color
|
|
19
|
+
@wip = wip
|
|
20
|
+
@scope = scope
|
|
21
|
+
@on_drop = on_drop
|
|
22
|
+
@collapsed = collapsed.nil? ? preset[:collapsed] : collapsed
|
|
23
|
+
@add = add.nil? ? preset[:add] : add
|
|
24
|
+
@accepts = accepts.nil? || accepts
|
|
25
|
+
@locked = locked || false
|
|
26
|
+
@actions = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def action(key, interaction:, on: :all, label: nil, icon: nil, confirmation: nil)
|
|
30
|
+
@actions << Action.new(key: key.to_sym, interaction:, on:, label:, icon:, confirmation:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def collapsed? = !!@collapsed
|
|
34
|
+
def add? = !!@add
|
|
35
|
+
def locked? = @locked
|
|
36
|
+
|
|
37
|
+
# Column-level accepts check — used for client-side drop hints and as the
|
|
38
|
+
# first gate in the move handler (before the record is needed).
|
|
39
|
+
# Proc accepts: is treated as permissive at the column level; call
|
|
40
|
+
# accepts_record? with the actual record to evaluate the predicate.
|
|
41
|
+
def accepts?(source_key)
|
|
42
|
+
case @accepts
|
|
43
|
+
when Array then @accepts.include?(source_key)
|
|
44
|
+
when true, false then @accepts
|
|
45
|
+
# Proc/predicate case: permit at the column level here; the move handler
|
|
46
|
+
# evaluates the predicate per-card via accepts_record? with the actual record.
|
|
47
|
+
else true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Per-card accepts check — evaluates a Proc accepts: against the actual
|
|
52
|
+
# record. Called by the move handler after the record is loaded.
|
|
53
|
+
#
|
|
54
|
+
# Convention for Proc accepts:
|
|
55
|
+
# accepts: ->(card) { … } # receives the record, returns true/false
|
|
56
|
+
#
|
|
57
|
+
# For non-Proc values the behaviour matches accepts?(source_key) exactly,
|
|
58
|
+
# so the move handler can unconditionally switch to accepts_record?.
|
|
59
|
+
def accepts_record?(record, source_key)
|
|
60
|
+
case @accepts
|
|
61
|
+
when Array then @accepts.include?(source_key)
|
|
62
|
+
when true, false then @accepts
|
|
63
|
+
when Proc then @accepts.call(record)
|
|
64
|
+
else true
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Plutonium
|
|
6
|
+
module Kanban
|
|
7
|
+
# Evaluation scope for dynamic `columns do…end` blocks at request time.
|
|
8
|
+
#
|
|
9
|
+
# Delegates everything to the request's view_context so the block can call
|
|
10
|
+
# current_user, current_scoped_entity, params, helpers, etc. directly —
|
|
11
|
+
# exactly like Plutonium::Action::ConditionContext does for action conditions.
|
|
12
|
+
class Context < SimpleDelegator
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Kanban
|
|
5
|
+
class DSL
|
|
6
|
+
def self.build(&block)
|
|
7
|
+
dsl = new
|
|
8
|
+
dsl.instance_eval(&block) if block
|
|
9
|
+
dsl.to_board
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@columns = []
|
|
14
|
+
@columns_block = nil
|
|
15
|
+
@card_fields = nil
|
|
16
|
+
@per_column = nil
|
|
17
|
+
@realtime = false
|
|
18
|
+
@position_config = Positioning::Config.default
|
|
19
|
+
@lazy = true
|
|
20
|
+
@show_in = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def column(key, **opts, &blk)
|
|
24
|
+
col = Column.new(key, **opts)
|
|
25
|
+
col.instance_eval(&blk) if blk
|
|
26
|
+
@columns << col
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Fluent DSL setters — `attr_writer` would change the call syntax
|
|
30
|
+
# (`per_column 25` → `self.per_column = 25`), so keep them as methods.
|
|
31
|
+
# standard:disable Style/TrivialAccessors
|
|
32
|
+
def columns(&blk) = @columns_block = blk
|
|
33
|
+
def card_fields(**slots) = @card_fields = slots
|
|
34
|
+
def per_column(n) = @per_column = n
|
|
35
|
+
def realtime(v = true) = @realtime = v
|
|
36
|
+
def lazy(v = true) = @lazy = v
|
|
37
|
+
# standard:enable Style/TrivialAccessors
|
|
38
|
+
|
|
39
|
+
# Overrides where a card click opens the record's show page, for this
|
|
40
|
+
# board only:
|
|
41
|
+
# :modal — open in a centered modal dialog
|
|
42
|
+
# :page — navigate the whole page to the show route
|
|
43
|
+
# When unset, the board inherits the definition's `show_in` (default :page).
|
|
44
|
+
def show_in(mode) = @show_in = mode # standard:disable Style/TrivialAccessors
|
|
45
|
+
|
|
46
|
+
def position_on(attr = :position, &blk)
|
|
47
|
+
@position_config =
|
|
48
|
+
if attr == false
|
|
49
|
+
Positioning::Config.disabled
|
|
50
|
+
elsif blk
|
|
51
|
+
Positioning::Config.with_block(attr, blk)
|
|
52
|
+
else
|
|
53
|
+
Positioning::Config.attribute(attr)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_board
|
|
58
|
+
Board.new(
|
|
59
|
+
columns: @columns,
|
|
60
|
+
columns_block: @columns_block,
|
|
61
|
+
card_fields: @card_fields,
|
|
62
|
+
per_column: @per_column,
|
|
63
|
+
realtime: @realtime,
|
|
64
|
+
position_config: @position_config,
|
|
65
|
+
lazy: @lazy,
|
|
66
|
+
show_in: @show_in
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Kanban
|
|
5
|
+
# Groups an already-authorized, query-applied, UN-paginated relation into
|
|
6
|
+
# ordered, per_column-capped column entries.
|
|
7
|
+
module Grouping
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Returns [{column:, cards: [records], total: Integer}, ...] in column order.
|
|
11
|
+
def call(board:, relation:, context:)
|
|
12
|
+
columns = resolve_columns(board, context)
|
|
13
|
+
pos = board.position_config
|
|
14
|
+
columns.map do |col|
|
|
15
|
+
scoped = apply_scope(relation, col.scope)
|
|
16
|
+
ordered = pos.order(scoped)
|
|
17
|
+
if board.per_column
|
|
18
|
+
total = ordered.count
|
|
19
|
+
cards = ordered.limit(board.per_column).to_a
|
|
20
|
+
else
|
|
21
|
+
cards = ordered.to_a
|
|
22
|
+
total = cards.size
|
|
23
|
+
end
|
|
24
|
+
{column: col, cards: cards, total: total}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Resolves the column list from a board. For dynamic boards, evaluates
|
|
29
|
+
# the columns_block against the context (which exposes current_user,
|
|
30
|
+
# params, etc. via delegation to view_context). Public so Task 7 (move
|
|
31
|
+
# handler) can call Grouping.resolve_columns(board, context) directly.
|
|
32
|
+
def resolve_columns(board, context)
|
|
33
|
+
return board.columns unless board.dynamic?
|
|
34
|
+
Array(context.instance_exec(&board.columns_block)).flatten
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Applies a column scope to a relation.
|
|
38
|
+
# Symbol → relation.public_send(sym) (named scope)
|
|
39
|
+
# Proc → relation.instance_exec(&scope) (inline lambda, e.g. -> { where(status: "todo") })
|
|
40
|
+
# nil → relation unchanged
|
|
41
|
+
def apply_scope(relation, scope)
|
|
42
|
+
case scope
|
|
43
|
+
when Symbol then relation.public_send(scope)
|
|
44
|
+
when Proc then relation.instance_exec(&scope)
|
|
45
|
+
when nil then relation
|
|
46
|
+
else raise ArgumentError, "Unsupported column scope: #{scope.inspect} (expected Symbol, Proc, or nil)"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|