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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. metadata +113 -2
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Kanban
5
+ class Column
6
+ ROLE_PRESETS = {
7
+ backlog: {add: true},
8
+ # Terminal columns: collapsed by default, colour signals the outcome.
9
+ # :done is the positive close (green); :lost is the negative close
10
+ # (red) — the natural pair for won/lost pipelines (leads, deals, tickets).
11
+ done: {color: :green, collapsed: true},
12
+ lost: {color: :red, collapsed: true}
13
+ }.freeze
14
+
15
+ attr_reader :key, :label, :color, :wip, :scope, :on_enter, :on_exit, :accepts, :actions, :enter_interaction
16
+
17
+ def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_enter: nil, on_exit: nil, on_drop: nil,
18
+ collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil, enter_interaction: nil, drop_interaction: nil)
19
+ # on_drop:/drop_interaction: were renamed to on_enter:/enter_interaction:.
20
+ # Resolve the deprecated aliases first (dev/test raise; deployed envs warn
21
+ # and map — see resolve_renamed_option) so the rest of initialize only
22
+ # ever sees the new names.
23
+ on_enter = resolve_renamed_option(:on_drop, on_drop, :on_enter, on_enter)
24
+ enter_interaction = resolve_renamed_option(:drop_interaction, drop_interaction, :enter_interaction, enter_interaction)
25
+
26
+ preset = role ? ROLE_PRESETS.fetch(role) { raise ArgumentError, "Unknown column role: #{role.inspect}. Valid: #{ROLE_PRESETS.keys.inspect}" } : {}
27
+ if enter_interaction && !(enter_interaction.is_a?(Class) && enter_interaction < Plutonium::Resource::Interaction)
28
+ raise ArgumentError, "enter_interaction: must be a Plutonium::Resource::Interaction subclass, got #{enter_interaction.inspect}"
29
+ end
30
+ # An enter_interaction acts on the SINGLE dropped card — the move handler
31
+ # binds it as `resource:`. It must therefore be record-shaped (declare a
32
+ # `resource` attribute), never collection/bulk-shaped (`resources`). Reject
33
+ # anything else at definition time so a mis-shaped interaction can't (a) blow
34
+ # up at drop time on the `resource=` assignment, or (b) get auto-classified
35
+ # by Action::Interactive::Factory as a bulk action and leak into the
36
+ # bulk-actions bar (which does not filter kanban_drop actions).
37
+ if enter_interaction && !enter_interaction.attribute_names.map(&:to_sym).include?(:resource)
38
+ raise ArgumentError, "enter_interaction: #{enter_interaction} must operate on a single record (declare `attribute :resource`); collection/bulk interactions cannot be used as an enter_interaction."
39
+ end
40
+ # accepts: is purely structural (topology + client drop hints): true/false
41
+ # or an Array of source keys. The Proc form was removed — record/user
42
+ # conditions belong in the kanban_move? policy, which sees the record and
43
+ # the from/to columns. Fail loud rather than silently treating a stale Proc
44
+ # as permissive (which would OPEN UP a column that used to restrict drops).
45
+ if accepts.is_a?(Proc)
46
+ raise ArgumentError, "kanban column `accepts:` no longer accepts a Proc; use true/false or an Array of source keys, and put record/user conditions in the kanban_move? policy."
47
+ end
48
+ @key = key.to_sym
49
+ @label = label || key.to_s.titleize
50
+ @color = color.nil? ? preset[:color] : color
51
+ @wip = wip
52
+ @scope = scope
53
+ @on_enter = on_enter
54
+ @on_exit = on_exit
55
+ @collapsed = collapsed.nil? ? preset[:collapsed] : collapsed
56
+ @add = add.nil? ? preset[:add] : add
57
+ @accepts = accepts.nil? || accepts
58
+ @locked = locked || false
59
+ @enter_interaction = enter_interaction
60
+ @actions = []
61
+ end
62
+
63
+ # A column may run an input-collecting Interaction when a card ENTERS it
64
+ # (e.g. "mark lead as lost with a reason"). When set, the drop opens the
65
+ # interaction's form as a modal before the move is committed.
66
+ def enter_interaction? = !!@enter_interaction
67
+
68
+ # Internal action-registration key for the enter interaction, scoped to the
69
+ # column: :blocked → :blocked_enter_interaction. Nil when unset.
70
+ #
71
+ # Column-scoped (not class-name-derived) so it is unique by construction — a
72
+ # column has at most one enter_interaction, so two columns can never collide
73
+ # even if they reuse the same interaction class. This key is ONLY an internal
74
+ # form/param routing handle; it is NOT an authorization name. The move (and
75
+ # therefore the interaction) is authorized solely by kanban_move? — the
76
+ # interaction has no policy method of its own.
77
+ def enter_interaction_key
78
+ return nil unless @enter_interaction
79
+ :"#{key}_enter_interaction"
80
+ end
81
+
82
+ def action(key, interaction:, on: :all, label: nil, icon: nil, confirmation: nil)
83
+ @actions << Action.new(key: key.to_sym, interaction:, on:, label:, icon:, confirmation:)
84
+ end
85
+
86
+ def collapsed? = !!@collapsed
87
+ def add? = !!@add
88
+ def locked? = @locked
89
+
90
+ # Whether a card from `source_key` may be dropped into this column. Purely
91
+ # structural — @accepts is normalized to true/false or an Array of source
92
+ # keys (the constructor rejects a Proc). Drives both the server-side gate in
93
+ # the move handler and the client-side drop hint (data-kanban-accepts).
94
+ def accepts?(source_key)
95
+ case @accepts
96
+ when Array then @accepts.include?(source_key)
97
+ else @accepts # true or false
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ # Bridge a renamed column option. `on_drop:`/`drop_interaction:` became
104
+ # `on_enter:`/`enter_interaction:`. To avoid breaking live deployments on
105
+ # upgrade we DON'T hard-fail in deployed envs — we log a deprecation and map
106
+ # the old value onto the new name. But local envs (development/test) raise,
107
+ # so the rename is caught during development and can't silently ship. If
108
+ # both the old and new names are given, the new one wins.
109
+ def resolve_renamed_option(old_name, old_value, new_name, new_value)
110
+ return new_value if old_value.nil?
111
+
112
+ if Rails.env.local?
113
+ raise ArgumentError,
114
+ "kanban column `#{old_name}:` has been renamed to `#{new_name}:` — update your definition."
115
+ end
116
+
117
+ Rails.logger.warn { "[plutonium] kanban column `#{old_name}:` is deprecated; rename it to `#{new_name}:`." }
118
+ new_value.nil? ? old_value : new_value
119
+ end
120
+ end
121
+ end
122
+ 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
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/positioning"
4
+
5
+ module Plutonium
6
+ module Kanban
7
+ module Positioning
8
+ # Value object passed to Mode B blocks, carrying the full drop context.
9
+ Move = Data.define(:record, :column, :prev, :next, :index)
10
+
11
+ # Strategy configuration object created by the `position_on` DSL.
12
+ #
13
+ # Three modes:
14
+ # Mode A (:delegate) — delegate reposition! to Plutonium::Positioning concern
15
+ # Mode B (:block) — call a user-supplied block with a Move
16
+ # Mode C (:disabled) — no ordering; relation returned unchanged
17
+ class Config
18
+ # Mode A, default attribute :position
19
+ def self.default
20
+ new(:delegate, :position, nil)
21
+ end
22
+
23
+ # Mode A, custom attribute
24
+ def self.attribute(attr)
25
+ new(:delegate, attr.to_sym, nil)
26
+ end
27
+
28
+ # Mode B — orders by attr, write delegated to block
29
+ def self.with_block(attr, block)
30
+ new(:block, attr.to_sym, block)
31
+ end
32
+
33
+ # Mode C — disabled
34
+ def self.disabled
35
+ new(:disabled, nil, nil)
36
+ end
37
+
38
+ attr_reader :attribute
39
+
40
+ def initialize(mode, attribute, block)
41
+ @mode = mode
42
+ @attribute = attribute
43
+ @block = block
44
+ end
45
+
46
+ def disabled?
47
+ @mode == :disabled
48
+ end
49
+
50
+ # Apply positional ordering to a relation.
51
+ # Mode A/B: relation.reorder(attribute)
52
+ # Mode C: return relation unchanged
53
+ def order(relation)
54
+ return relation if disabled?
55
+ relation.reorder(@attribute)
56
+ end
57
+
58
+ # Persist the new position for a dropped record.
59
+ # Mode A: delegate to record.reposition!(prev_record:, next_record:)
60
+ # Mode B: call the user block with a Move
61
+ # Mode C: no-op
62
+ def reposition!(record:, column:, prev_record:, next_record:, index:)
63
+ case @mode
64
+ when :delegate
65
+ record.reposition!(prev_record:, next_record:)
66
+ when :block
67
+ @block.call(Move.new(record:, column:, prev: prev_record, next: next_record, index:))
68
+ when :disabled
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/positioning"
4
+ require "plutonium/kanban/positioning"
5
+ require "plutonium/kanban/action"
6
+ require "plutonium/kanban/column"
7
+ require "plutonium/kanban/board"
8
+ require "plutonium/kanban/dsl"
9
+ require "plutonium/kanban/context"
10
+ require "plutonium/kanban/grouping"
11
+ require "plutonium/kanban/broadcaster"
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ # Registry of per-feature migration paths.
5
+ #
6
+ # Features register their migration directory against a configuration key. A
7
+ # path is only surfaced by {.enabled_paths} when its feature's config
8
+ # (+Plutonium.configuration.<feature>+) responds to +.enabled+ and it is true.
9
+ module Migrations
10
+ @registry = {}
11
+
12
+ class << self
13
+ # Register a migration path for a feature.
14
+ #
15
+ # @param feature [Symbol, String] the configuration key (e.g. +:wizards+)
16
+ # @param path [String] the absolute migration directory path
17
+ # @return [String] the registered path
18
+ def register(feature, path)
19
+ @registry[feature.to_sym] = path
20
+ end
21
+
22
+ # Clear the registry. Intended for tests.
23
+ #
24
+ # @return [Hash]
25
+ def reset!
26
+ @registry = {}
27
+ end
28
+
29
+ # Migration paths for features that are currently enabled.
30
+ #
31
+ # @return [Array<String>]
32
+ def enabled_paths
33
+ @registry.filter_map do |feature, path|
34
+ cfg = Plutonium.configuration.public_send(feature) if Plutonium.configuration.respond_to?(feature)
35
+ path if cfg&.respond_to?(:enabled) && cfg.enabled
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -76,6 +76,24 @@ module Plutonium
76
76
  class_attribute :has_cents_attributes, instance_writer: false, default: {}
77
77
  end
78
78
 
79
+ # Resolves the currency unit (symbol) configured for a has_cents decimal
80
+ # accessor, e.g. :price for `has_cents :price_cents, unit: "£"`.
81
+ #
82
+ # @param decimal_attribute [Symbol, String] The decimal accessor name.
83
+ # @return [String, nil] The unit — a String verbatim, the value of the
84
+ # method named by a Symbol unit (per-row currencies), or nil when the
85
+ # attribute has no unit configured / is not a has_cents accessor.
86
+ def has_cents_unit_for(decimal_attribute)
87
+ decimal_attribute = decimal_attribute.to_sym
88
+ config = self.class.has_cents_attributes.values.find { |opts| opts[:name] == decimal_attribute }
89
+ return unless config
90
+
91
+ case (unit = config[:unit])
92
+ when Symbol then public_send(unit)
93
+ else unit
94
+ end
95
+ end
96
+
79
97
  module ClassMethods
80
98
  # # Inherit validations from cents attribute to decimal attribute
81
99
  # def validate(*args, &block)
@@ -109,10 +127,20 @@ module Plutonium
109
127
  # - rate: 1000 for dollars/mils (1 dollar = 1000 mils)
110
128
  # - rate: 1 for a whole number representation
111
129
  # @param suffix [String] The suffix to append to the cents_name if name is not provided (default: "amount").
130
+ # @param unit [String, Symbol, nil] The currency unit used when the value is rendered as currency.
131
+ # A String is used verbatim as the symbol ("£"); a Symbol names a method read off the record for
132
+ # per-row currencies (`unit: :currency_symbol`); nil (default) renders with no symbol. Consumers
133
+ # (the Currency display component, grid/kanban cards) read this via {#has_cents_unit_for}.
112
134
  #
113
135
  # @example Standard currency (dollars and cents)
114
136
  # has_cents :price_cents
115
137
  #
138
+ # @example Static currency symbol
139
+ # has_cents :price_cents, unit: "£"
140
+ #
141
+ # @example Per-row currency symbol read off the record
142
+ # has_cents :price_cents, unit: :currency_symbol
143
+ #
116
144
  # @example Custom rate for a different currency division
117
145
  # has_cents :amount_cents, name: :cost, rate: 1000
118
146
  #
@@ -121,14 +149,14 @@ module Plutonium
121
149
  #
122
150
  # @example Using custom suffix
123
151
  # has_cents :total_cents, suffix: "value"
124
- def has_cents(cents_name, name: nil, rate: 100, suffix: "amount")
152
+ def has_cents(cents_name, name: nil, rate: 100, suffix: "amount", unit: nil)
125
153
  cents_name = cents_name.to_sym
126
154
  name ||= cents_name.to_s.gsub(/_cents$/, "")
127
155
  name = name.to_sym
128
156
  name = (name == cents_name) ? :"#{cents_name}_#{suffix}" : name
129
157
 
130
158
  self.has_cents_attributes = has_cents_attributes.merge(
131
- cents_name => {name: name, rate: rate}
159
+ cents_name => {name: name, rate: rate, unit: unit}
132
160
  )
133
161
 
134
162
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ # Standalone decimal/fractional ordering. Kanban-independent.
5
+ #
6
+ # Including this concern and calling `positioned_on` gives a model:
7
+ # - automatic position assignment on create (appends to the end of its scope group)
8
+ # - `reposition!(prev_record:, next_record:)` for drag-and-drop reordering
9
+ # - `backfill_positions!` class method to number existing rows
10
+ #
11
+ # Pure math helpers are exposed as module-level methods so they can be
12
+ # called without an AR instance:
13
+ # Plutonium::Positioning.position_between(1.0, 3.0) # => 2.0
14
+ # Plutonium::Positioning.gap_exhausted?(1.0, 1.0) # => true
15
+ module Positioning
16
+ extend ActiveSupport::Concern
17
+
18
+ EPSILON = 1e-6
19
+
20
+ # Returns the position that sits between +prev_val+ and +next_val+.
21
+ #
22
+ # Rules:
23
+ # both nil → 0.0 (first item in an empty list)
24
+ # prev nil → next_val - 1 (prepend)
25
+ # next nil → prev_val + 1 (append)
26
+ # else → midpoint
27
+ def self.position_between(prev_val, next_val)
28
+ return 0.0 if prev_val.nil? && next_val.nil?
29
+ return next_val - 1 if prev_val.nil?
30
+ return prev_val + 1 if next_val.nil?
31
+ (prev_val + next_val) / 2.0
32
+ end
33
+
34
+ # Returns true when +prev_val+ and +next_val+ are so close together
35
+ # that inserting a new midpoint would produce a duplicate.
36
+ def self.gap_exhausted?(prev_val, next_val)
37
+ return false if prev_val.nil? || next_val.nil?
38
+ (next_val - prev_val).abs < EPSILON
39
+ end
40
+
41
+ included do
42
+ class_attribute :positioning_column, instance_accessor: false, default: :position
43
+ class_attribute :positioning_scope_attr, instance_accessor: false, default: nil
44
+ end
45
+
46
+ class_methods do
47
+ # Opt in to positional ordering.
48
+ #
49
+ # positioned_on :position, scope: :status
50
+ #
51
+ # @param column [Symbol] the decimal column that stores positions
52
+ # @param scope [Symbol, nil] group rows by this column; nil = single global group
53
+ def positioned_on(column = :position, scope: nil)
54
+ self.positioning_column = column
55
+ self.positioning_scope_attr = scope
56
+ before_create :assign_initial_position
57
+ end
58
+
59
+ # Number every row in the table per scope group as 1.0, 2.0, … in
60
+ # +order+ order. Safe to call on an empty table.
61
+ #
62
+ # @param order [Symbol] column to sort by when assigning positions
63
+ def backfill_positions!(order: :created_at)
64
+ groups = positioning_scope_attr ? all.group_by(&positioning_scope_attr) : {nil => all.to_a}
65
+ groups.each_value do |rows|
66
+ ActiveRecord::Base.transaction do
67
+ rows.sort_by { |r| r.public_send(order) }.each_with_index do |row, i|
68
+ row.update_column(positioning_column, (i + 1).to_f)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ # Move this record so it sits between +prev_record+ and +next_record+
76
+ # within its scope group. Pass nil for either neighbor to move to an end.
77
+ #
78
+ # If the gap between the two neighbors is exhausted (too small to split)
79
+ # the scope group is rebalanced first so that fresh integer positions are
80
+ # available, then the record is positioned between the reloaded neighbors.
81
+ #
82
+ # @param prev_record [ActiveRecord::Base, nil]
83
+ # @param next_record [ActiveRecord::Base, nil]
84
+ def reposition!(prev_record:, next_record:)
85
+ col = self.class.positioning_column
86
+ prev_val = prev_record&.public_send(col)
87
+ next_val = next_record&.public_send(col)
88
+ if Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
89
+ rebalance_scope_group!
90
+ prev_val = prev_record&.reload&.public_send(col)
91
+ next_val = next_record&.reload&.public_send(col)
92
+ end
93
+ update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
94
+ end
95
+
96
+ private
97
+
98
+ def assign_initial_position
99
+ col = self.class.positioning_column
100
+ return if public_send(col).present?
101
+ max = positioning_group_relation.maximum(col) || 0.0
102
+ public_send(:"#{col}=", max + 1)
103
+ end
104
+
105
+ def positioning_group_relation
106
+ rel = self.class.all
107
+ attr = self.class.positioning_scope_attr
108
+ attr ? rel.where(attr => public_send(attr)) : rel
109
+ end
110
+
111
+ def rebalance_scope_group!
112
+ col = self.class.positioning_column
113
+ ActiveRecord::Base.transaction do
114
+ positioning_group_relation.order(col).each_with_index do |row, i|
115
+ row.update_column(col, (i + 1).to_f)
116
+ end
117
+ end
118
+ end
119
+
120
+ # Migration helper that adds a position column pre-tuned for fractional
121
+ # ordering. Mixed into ActiveRecord's table-definition classes by the
122
+ # railtie, so it is available in both create_table and change_table:
123
+ #
124
+ # create_table :tasks do |t|
125
+ # t.position # decimal :position, precision: 16, scale: 8
126
+ # t.position :sort_order # custom column name
127
+ # t.position index: true # also add a single-column index
128
+ # end
129
+ #
130
+ # change_table :tasks do |t|
131
+ # t.position
132
+ # end
133
+ #
134
+ # The precision/scale give midpoints ample headroom over the rebalance
135
+ # threshold (EPSILON = 1e-6) — a too-small scale lets the last subdivision
136
+ # round to a neighbor. Pass precision:/scale: to override.
137
+ module MigrationHelpers
138
+ DEFAULT_PRECISION = 16
139
+ DEFAULT_SCALE = 8
140
+
141
+ def position(name = :position, **options)
142
+ column name, :decimal, precision: DEFAULT_PRECISION, scale: DEFAULT_SCALE, **options
143
+ end
144
+ end
145
+ end
146
+ end
@@ -39,6 +39,30 @@ module Plutonium
39
39
  load_plutonium_initializers
40
40
  end
41
41
 
42
+ initializer "plutonium.register_migrations" do
43
+ Plutonium::Migrations.register(:wizards, Plutonium.root.join("db/migrate/wizard").to_s)
44
+ end
45
+
46
+ # Runs after the host's config/initializers/plutonium.rb (load_config_initializers)
47
+ # so feature flags like config.wizards.enabled are honoured. Railtie
48
+ # initializers otherwise run before host initializers, leaving the flag false.
49
+ initializer "plutonium.migrations", after: :load_config_initializers do |app|
50
+ # v1 supports `config.wizards.database == :primary` only; the option is
51
+ # reserved for future multi-db routing of wizard sessions. Fail loudly on an
52
+ # unsupported value rather than silently registering on the primary database.
53
+ if Plutonium.configuration.wizards.enabled &&
54
+ Plutonium.configuration.wizards.database != :primary
55
+ raise ArgumentError,
56
+ "config.wizards.database = #{Plutonium.configuration.wizards.database.inspect} is not " \
57
+ "supported (only :primary). Multi-database routing for wizard sessions is a roadmap follow-up."
58
+ end
59
+
60
+ Plutonium::Migrations.enabled_paths.each do |path|
61
+ app.config.paths["db/migrate"] << path
62
+ ActiveRecord::Migrator.migrations_paths << path unless ActiveRecord::Migrator.migrations_paths.include?(path)
63
+ end
64
+ end
65
+
42
66
  initializer "plutonium.asset_server" do
43
67
  setup_development_asset_server if Plutonium.configuration.development?
44
68
  end
@@ -47,6 +71,14 @@ module Plutonium
47
71
  extend_action_dispatch
48
72
  end
49
73
 
74
+ # Make `t.position` available in create_table / change_table blocks.
75
+ initializer "plutonium.positioning" do
76
+ ActiveSupport.on_load(:active_record) do
77
+ ActiveRecord::ConnectionAdapters::TableDefinition.include(Plutonium::Positioning::MigrationHelpers)
78
+ ActiveRecord::ConnectionAdapters::Table.include(Plutonium::Positioning::MigrationHelpers)
79
+ end
80
+ end
81
+
50
82
  initializer "plutonium.phlexi_themes" do
51
83
  setup_phlexi_themes
52
84
  end
@@ -97,6 +129,7 @@ module Plutonium
97
129
 
98
130
  def extend_action_dispatch
99
131
  ActionDispatch::Routing::Mapper.prepend Plutonium::Routing::MapperExtensions
132
+ ActionDispatch::Routing::Mapper.prepend Plutonium::Routing::WizardRegistration
100
133
  ActionDispatch::Routing::RouteSet.prepend Plutonium::Routing::RouteSetExtensions
101
134
  Rails::Engine.include Plutonium::Routing::ResourceRegistration
102
135
  end