plutonium 0.61.0 → 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-kanban/SKILL.md +89 -24
- data/CHANGELOG.md +27 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +315 -38
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +31 -31
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/docs/.vitepress/config.ts +21 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/kanban.md +128 -18
- data/docs/reference/kanban/authorization.md +25 -5
- data/docs/reference/kanban/dsl.md +49 -8
- data/docs/reference/kanban/index.md +3 -3
- data/docs/reference/kanban/positioning.md +1 -1
- data/docs/reference/resource/definition.md +10 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +41 -0
- data/docs/reference/wizard/dsl.md +5 -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-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/plutonium/action/base.rb +8 -0
- data/lib/plutonium/configuration.rb +12 -0
- data/lib/plutonium/definition/index_views.rb +16 -0
- data/lib/plutonium/kanban/column.rb +80 -27
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/resource/controller.rb +22 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
- data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
- data/lib/plutonium/resource/policy.rb +6 -0
- data/lib/plutonium/routing/mapper_extensions.rb +1 -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/form/base.rb +6 -0
- 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/uppy.rb +20 -2
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
- data/lib/plutonium/ui/form/resource.rb +12 -0
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/grid/card.rb +40 -13
- data/lib/plutonium/ui/kanban/column.rb +111 -24
- data/lib/plutonium/ui/kanban/resource.rb +118 -11
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +1 -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 +7 -2
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +5 -3
- data/src/css/components.css +5 -0
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +442 -55
- data/src/js/controllers/register_controllers.js +2 -0
- data/yarn.lock +674 -4
- metadata +14 -2
|
@@ -53,7 +53,19 @@ module Plutonium
|
|
|
53
53
|
# Resolved by the controller and threaded through to Kanban::Card.
|
|
54
54
|
# Defaults to "_top" so a card always escapes the column's lazy frame when
|
|
55
55
|
# the component is built outside a controller (tests, board shell).
|
|
56
|
-
|
|
56
|
+
# collapsed: the EFFECTIVE collapse state for this render (the user's
|
|
57
|
+
# cookie-persisted choice resolved against the column default by the
|
|
58
|
+
# controller). nil → fall back to the column's declared default. The
|
|
59
|
+
# component still emits the DEFAULT separately (data-kanban-default-
|
|
60
|
+
# collapsed) so the controller can store only deltas from it.
|
|
61
|
+
# drop_form_url_template: the kanban_move_form member URL with an __ID__
|
|
62
|
+
# placeholder for the dragged card's record id (mirrors the board's
|
|
63
|
+
# move-url template). Set by the controller ONLY for columns that declare
|
|
64
|
+
# an enter_interaction:; nil for plain columns. When present, the wrapper
|
|
65
|
+
# advertises data-kanban-drop-interaction + data-kanban-drop-form-url-
|
|
66
|
+
# template so Task 6's Stimulus controller opens the interaction modal on
|
|
67
|
+
# drop instead of committing the move immediately.
|
|
68
|
+
def initialize(column:, cards:, total:, per_column:, resource_definition:, resource_fields:, column_action_data: [], column_add_url: nil, board_url: nil, card_fields: nil, card_show_frame: "_top", collapsed: nil, drop_form_url_template: nil, drop_immediate: false, drop_confirm: nil)
|
|
57
69
|
@column = column
|
|
58
70
|
@cards = cards
|
|
59
71
|
@total = total
|
|
@@ -62,8 +74,19 @@ module Plutonium
|
|
|
62
74
|
@resource_fields = resource_fields
|
|
63
75
|
@column_action_data = column_action_data
|
|
64
76
|
@column_add_url = column_add_url
|
|
77
|
+
@board_url = board_url
|
|
65
78
|
@card_fields = card_fields
|
|
66
79
|
@card_show_frame = card_show_frame
|
|
80
|
+
@collapsed = collapsed
|
|
81
|
+
@drop_form_url_template = drop_form_url_template
|
|
82
|
+
@drop_immediate = drop_immediate
|
|
83
|
+
@drop_confirm = drop_confirm
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Effective collapse state: the caller's resolved value, or the column
|
|
87
|
+
# default when none was passed (tests / board shell / non-cookie paths).
|
|
88
|
+
def effective_collapsed?
|
|
89
|
+
@collapsed.nil? ? column.collapsed? : @collapsed
|
|
67
90
|
end
|
|
68
91
|
|
|
69
92
|
def view_template
|
|
@@ -73,13 +96,9 @@ module Plutonium
|
|
|
73
96
|
div(
|
|
74
97
|
class: tokens(
|
|
75
98
|
"pu-kanban-column-wrapper",
|
|
76
|
-
|
|
99
|
+
effective_collapsed? && "pu-kanban-column-collapsed"
|
|
77
100
|
),
|
|
78
|
-
data:
|
|
79
|
-
kanban_col: column.key.to_s,
|
|
80
|
-
kanban_accepts: accepts_value,
|
|
81
|
-
kanban_locked: column.locked?.to_s
|
|
82
|
-
}
|
|
101
|
+
data: wrapper_data
|
|
83
102
|
) do
|
|
84
103
|
render_collapsed_strip
|
|
85
104
|
render_expanded
|
|
@@ -88,6 +107,33 @@ module Plutonium
|
|
|
88
107
|
|
|
89
108
|
private
|
|
90
109
|
|
|
110
|
+
# Data attributes for the [data-kanban-col] wrapper. Drop-policy hints
|
|
111
|
+
# (accepts/locked) and the collapse default are always present; the
|
|
112
|
+
# drop-interaction attributes are added only when the column declares an
|
|
113
|
+
# enter_interaction: (advertised via the controller-computed template).
|
|
114
|
+
def wrapper_data
|
|
115
|
+
data = {
|
|
116
|
+
kanban_col: column.key.to_s,
|
|
117
|
+
# The DEFAULT (not the effective state) so the controller can store
|
|
118
|
+
# only a delta from it in the cookie.
|
|
119
|
+
kanban_default_collapsed: column.collapsed?.to_s,
|
|
120
|
+
kanban_accepts: accepts_value,
|
|
121
|
+
kanban_locked: column.locked?.to_s
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if column.enter_interaction?
|
|
125
|
+
data[:kanban_drop_interaction] = "true"
|
|
126
|
+
data[:kanban_drop_form_url_template] = @drop_form_url_template
|
|
127
|
+
# An input-less interaction commits directly on drop (no form modal);
|
|
128
|
+
# the client reads this to skip opening an empty modal. The optional
|
|
129
|
+
# confirm message (auto "<label>?" for immediate actions) gates it.
|
|
130
|
+
data[:kanban_drop_immediate] = "true" if @drop_immediate
|
|
131
|
+
data[:kanban_drop_confirm] = @drop_confirm if @drop_confirm.present?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
data
|
|
135
|
+
end
|
|
136
|
+
|
|
91
137
|
# ---------------------------------------------------------------
|
|
92
138
|
# Collapsed strip
|
|
93
139
|
# ---------------------------------------------------------------
|
|
@@ -102,6 +148,9 @@ module Plutonium
|
|
|
102
148
|
"rounded-[var(--pu-radius-md)] select-none",
|
|
103
149
|
data: {kanban_role: "strip"}
|
|
104
150
|
) do
|
|
151
|
+
# The color dot is the outcome signal for terminal columns (:done
|
|
152
|
+
# green, :lost red) — keep it visible when collapsed too.
|
|
153
|
+
render_color_dot(column.color) if column.color
|
|
105
154
|
span(
|
|
106
155
|
class: "text-xs font-semibold text-[var(--pu-text-muted)] " \
|
|
107
156
|
"[writing-mode:vertical-lr] rotate-180"
|
|
@@ -120,7 +169,9 @@ module Plutonium
|
|
|
120
169
|
kanban_column_key: column.key.to_s
|
|
121
170
|
}
|
|
122
171
|
) { plain "▶" }
|
|
123
|
-
|
|
172
|
+
# No column actions here: a collapsed column is a thin strip, so its
|
|
173
|
+
# bulk actions (and "+ Add") stay hidden until it's expanded. They
|
|
174
|
+
# live only in the expanded header (render_header).
|
|
124
175
|
end
|
|
125
176
|
end
|
|
126
177
|
|
|
@@ -147,7 +198,13 @@ module Plutonium
|
|
|
147
198
|
div(class: "flex items-center gap-2 min-w-0 flex-1") do
|
|
148
199
|
render_color_dot(column.color) if column.color
|
|
149
200
|
span(class: "font-semibold text-sm text-[var(--pu-text)] truncate") { plain column.label }
|
|
150
|
-
|
|
201
|
+
# Always show a card count (matching the collapsed strip); when a
|
|
202
|
+
# WIP limit is set it becomes the "count/limit" badge instead.
|
|
203
|
+
if column.wip
|
|
204
|
+
render_wip_badge
|
|
205
|
+
else
|
|
206
|
+
render_count_badge
|
|
207
|
+
end
|
|
151
208
|
end
|
|
152
209
|
render_column_actions if @column_add_url || column.actions.any?
|
|
153
210
|
# Collapse toggle — always present in the expanded header so the
|
|
@@ -167,6 +224,12 @@ module Plutonium
|
|
|
167
224
|
end
|
|
168
225
|
end
|
|
169
226
|
|
|
227
|
+
# Plain card-count badge for columns without a WIP limit — mirrors the
|
|
228
|
+
# count shown on the collapsed strip so the number is visible either way.
|
|
229
|
+
def render_count_badge
|
|
230
|
+
span(class: "pu-badge pu-badge-neutral text-xs font-mono") { plain cards.size.to_s }
|
|
231
|
+
end
|
|
232
|
+
|
|
170
233
|
def render_wip_badge
|
|
171
234
|
over = wip_over_limit?
|
|
172
235
|
span(
|
|
@@ -243,15 +306,17 @@ module Plutonium
|
|
|
243
306
|
# Renders bulk-action links for each column action, and the "+ Add"
|
|
244
307
|
# quick-add button when column_add_url is present.
|
|
245
308
|
#
|
|
246
|
-
# Each
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
309
|
+
# Each link targets /resources/bulk_actions/:key?ids[]=… — the same path
|
|
310
|
+
# for the GET form and the POST commit. The render mirrors ActionButton so
|
|
311
|
+
# the action's shape is honoured (see #column_action_link_data):
|
|
312
|
+
# • an interaction with NO user inputs is `immediate` → POST + confirm,
|
|
313
|
+
# executed directly instead of opening an empty form modal;
|
|
314
|
+
# • one with inputs opens the interaction form in the remote modal.
|
|
252
315
|
#
|
|
253
|
-
# The
|
|
254
|
-
#
|
|
316
|
+
# The action is only rendered when it's registered in defined_actions
|
|
317
|
+
# (auto-registered by Definition::IndexViews.kanban) and permitted by the
|
|
318
|
+
# policy. The bulk endpoint re-authorizes each record, so this is a
|
|
319
|
+
# display-only gate, not the security boundary.
|
|
255
320
|
def render_column_actions
|
|
256
321
|
div(class: "flex items-center gap-1 shrink-0") do
|
|
257
322
|
render_add_button if @column_add_url
|
|
@@ -268,19 +333,18 @@ module Plutonium
|
|
|
268
333
|
# if clicked. An empty column simply renders no action link.
|
|
269
334
|
next if ids.empty?
|
|
270
335
|
|
|
271
|
-
|
|
336
|
+
# return_to the board so the action redirects back to it (not the
|
|
337
|
+
# table index) AND the board refreshes: the board-bound redirect is
|
|
338
|
+
# tagged with kanban_reload (KanbanActions#kanban_reload_url), so the
|
|
339
|
+
# column re-fetches and the mutated cards (e.g. archived) update.
|
|
340
|
+
url = resource_url_for(resource_class, interaction: col_action.key, ids: ids, return_to: @board_url)
|
|
272
341
|
label = col_action.label || col_action.key.to_s.humanize
|
|
273
|
-
data_attrs = {
|
|
274
|
-
kanban_action: col_action.key.to_s,
|
|
275
|
-
kanban_column: column.key.to_s
|
|
276
|
-
}
|
|
277
|
-
data_attrs[:turbo_confirm] = col_action.confirmation if col_action.confirmation
|
|
278
342
|
|
|
279
343
|
link_to(
|
|
280
344
|
url,
|
|
281
345
|
class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
|
|
282
346
|
title: label,
|
|
283
|
-
data:
|
|
347
|
+
data: column_action_link_data(col_action, registered)
|
|
284
348
|
) do
|
|
285
349
|
render col_action.icon.new(class: "h-4 w-4") if col_action.icon
|
|
286
350
|
plain label
|
|
@@ -289,6 +353,29 @@ module Plutonium
|
|
|
289
353
|
end
|
|
290
354
|
end
|
|
291
355
|
|
|
356
|
+
# Data attributes for a column-action link, honouring the interaction's
|
|
357
|
+
# shape. `immediate` actions (no inputs) POST straight to the commit route
|
|
358
|
+
# with a confirmation, executed directly; the rest open the form in the
|
|
359
|
+
# remote modal. The confirmation prefers the DSL-supplied one, falling
|
|
360
|
+
# back to the action's own default ("<label>?" for immediate actions).
|
|
361
|
+
def column_action_link_data(col_action, registered)
|
|
362
|
+
data = {
|
|
363
|
+
kanban_action: col_action.key.to_s,
|
|
364
|
+
kanban_column: column.key.to_s
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if registered.immediate
|
|
368
|
+
data[:turbo_method] = :post
|
|
369
|
+
confirmation = col_action.confirmation || registered.confirmation
|
|
370
|
+
data[:turbo_confirm] = confirmation if confirmation.present?
|
|
371
|
+
else
|
|
372
|
+
data[:turbo_frame] = Plutonium::REMOTE_MODAL_FRAME
|
|
373
|
+
data[:turbo_confirm] = col_action.confirmation if col_action.confirmation
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
data
|
|
377
|
+
end
|
|
378
|
+
|
|
292
379
|
# ---------------------------------------------------------------
|
|
293
380
|
# Pure helpers
|
|
294
381
|
# ---------------------------------------------------------------
|
|
@@ -24,6 +24,28 @@ module Plutonium
|
|
|
24
24
|
include Phlex::Rails::Helpers::TurboFrameTag
|
|
25
25
|
include Phlex::Rails::Helpers::TurboStreamFrom
|
|
26
26
|
|
|
27
|
+
# Per-board collapse cookie. Stores ONLY the column keys whose collapse
|
|
28
|
+
# state differs from the server default (a compact delta): a board sitting
|
|
29
|
+
# at its defaults writes nothing, and toggling a column back to default
|
|
30
|
+
# drops its key — so the cookie can't balloon. Path-scoped to the engine
|
|
31
|
+
# mount so it rides only this board's requests. The server reads it to
|
|
32
|
+
# render each column in the user's state (no client re-apply / FOUC); the
|
|
33
|
+
# kanban controller writes it on toggle.
|
|
34
|
+
def self.collapse_cookie_name(resource_class)
|
|
35
|
+
"pu_kbc_#{resource_class.name.gsub("::", "_").underscore}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.collapse_cookie_path(request)
|
|
39
|
+
path = request.script_name.to_s
|
|
40
|
+
path.empty? ? "/" : path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The set of column keys (strings) flipped from their default, parsed
|
|
44
|
+
# from the cookie value. Order/whitespace-tolerant; blanks dropped.
|
|
45
|
+
def self.collapse_flips(cookie_value)
|
|
46
|
+
cookie_value.to_s.split(",").filter_map { |k| k.strip.presence }
|
|
47
|
+
end
|
|
48
|
+
|
|
27
49
|
attr_reader :board, :grouped_data, :resource_definition, :resource_fields,
|
|
28
50
|
:resource_class, :scoped_entity
|
|
29
51
|
|
|
@@ -64,18 +86,31 @@ module Plutonium
|
|
|
64
86
|
render_toolbar
|
|
65
87
|
|
|
66
88
|
div(
|
|
89
|
+
# `data-turbo-permanent` freezes the loaded board across index
|
|
90
|
+
# navigations (search / filter / scope all change the URL): Turbo
|
|
91
|
+
# transplants THIS element instead of re-rendering it as empty lazy
|
|
92
|
+
# shells, so the columns never blank. The `kanban` controller then
|
|
93
|
+
# syncs the frozen frames to the new URL (morphing cards in place).
|
|
94
|
+
# The id MUST be resource-scoped — a shared id would make Turbo
|
|
95
|
+
# preserve one resource's board when navigating to another's.
|
|
96
|
+
id: "kanban-board-#{resource_class.model_name.param_key}",
|
|
67
97
|
class: "pu-kanban-board flex gap-4 overflow-x-auto p-4 min-h-0",
|
|
68
98
|
data: {
|
|
99
|
+
turbo_permanent: true,
|
|
69
100
|
controller: "kanban",
|
|
70
101
|
# Stimulus value consumed by the drag controller to build the
|
|
71
102
|
# per-record move URL at drop time. The collection path comes from
|
|
72
103
|
# request.path so tenant / engine scoping is preserved automatically.
|
|
73
104
|
# Example: /admin/tasks/__ID__/kanban_move
|
|
74
|
-
kanban_move_url_template_value: kanban_move_url_template
|
|
105
|
+
kanban_move_url_template_value: kanban_move_url_template,
|
|
106
|
+
# Name + path the controller uses to write the collapse cookie —
|
|
107
|
+
# must match what the server reads (see collapse_cookie_name/path).
|
|
108
|
+
kanban_collapse_cookie_value: self.class.collapse_cookie_name(resource_class),
|
|
109
|
+
kanban_collapse_path_value: self.class.collapse_cookie_path(request)
|
|
75
110
|
}
|
|
76
111
|
) do
|
|
77
112
|
grouped_data.each do |entry|
|
|
78
|
-
render_column_frame(entry[:column])
|
|
113
|
+
render_column_frame(entry[:column], collapsed: entry[:collapsed])
|
|
79
114
|
end
|
|
80
115
|
end
|
|
81
116
|
|
|
@@ -157,29 +192,101 @@ module Plutonium
|
|
|
157
192
|
)
|
|
158
193
|
end
|
|
159
194
|
|
|
160
|
-
def render_column_frame(column)
|
|
161
|
-
|
|
195
|
+
def render_column_frame(column, collapsed: false)
|
|
196
|
+
# `refresh: "morph"` marks the frame morphable; `data-kanban-col-frame`
|
|
197
|
+
# lets the `kanban` controller find each column frame and rewrite its
|
|
198
|
+
# src to the current URL params (search / filter / scope) so cards
|
|
199
|
+
# morph in place instead of the frame blanking on reload.
|
|
200
|
+
attrs = {
|
|
201
|
+
src: column_frame_src(column),
|
|
202
|
+
refresh: "morph",
|
|
203
|
+
data: {kanban_col_frame: column.key}
|
|
204
|
+
}
|
|
162
205
|
attrs[:loading] = "lazy" if board.lazy?
|
|
163
206
|
|
|
164
207
|
turbo_frame_tag(column_frame_id(column), **attrs) do
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
208
|
+
# The placeholder mirrors the SHAPE of the loaded column (a thin strip
|
|
209
|
+
# when collapsed, the full w-72 box otherwise) so the lazy body load
|
|
210
|
+
# doesn't snap between orientations/widths — a collapsed column that
|
|
211
|
+
# renders as an open header and then flips shut is the FOUC this fixes.
|
|
212
|
+
if collapsed
|
|
213
|
+
render_collapsed_placeholder(column)
|
|
214
|
+
else
|
|
215
|
+
render_column_placeholder(column)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Full-column placeholder: matches the loaded expanded column box (w-72)
|
|
221
|
+
# so an expanded column doesn't resize when its body arrives.
|
|
222
|
+
def render_column_placeholder(column)
|
|
223
|
+
div(class: "pu-kanban-column w-72 shrink-0 flex flex-col bg-[var(--pu-surface-alt)] " \
|
|
224
|
+
"border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden") do
|
|
168
225
|
render_column_header(column)
|
|
169
226
|
end
|
|
170
227
|
end
|
|
171
228
|
|
|
172
|
-
#
|
|
173
|
-
#
|
|
229
|
+
# Strip placeholder: matches the loaded collapsed strip's full layout
|
|
230
|
+
# (w-10, justify-between, dot + vertical label + count badge + expand
|
|
231
|
+
# icon) so a collapsed column paints as a strip from the first frame AND
|
|
232
|
+
# doesn't grow/redistribute when the real strip (with its count + button)
|
|
233
|
+
# swaps in. The count/icon are reserved as invisible-/subtle skeletons.
|
|
234
|
+
def render_collapsed_placeholder(column)
|
|
235
|
+
div(
|
|
236
|
+
class: "pu-kanban-strip w-10 shrink-0 flex flex-col items-center justify-between py-3 gap-2 " \
|
|
237
|
+
"bg-[var(--pu-surface)] border border-[var(--pu-border)] " \
|
|
238
|
+
"rounded-[var(--pu-radius-md)] select-none",
|
|
239
|
+
data: {kanban_role: "strip"}
|
|
240
|
+
) do
|
|
241
|
+
render_color_dot(column.color) if column.color
|
|
242
|
+
span(
|
|
243
|
+
class: "text-xs font-semibold text-[var(--pu-text-muted)] " \
|
|
244
|
+
"[writing-mode:vertical-lr] rotate-180"
|
|
245
|
+
) { plain column.label }
|
|
246
|
+
# The strip ALWAYS shows a card-count badge + expand button when
|
|
247
|
+
# loaded, so reserve both (unlike the header's wip-only count).
|
|
248
|
+
span(class: "pu-badge pu-badge-neutral text-xs font-mono opacity-0", aria_hidden: "true") { plain "0" }
|
|
249
|
+
span(class: "p-0.5 text-[var(--pu-text-subtle)]", aria_hidden: "true") { plain "▶" }
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Mirrors Kanban::Column#render_header's LAYOUT (dot + label, plus reserved
|
|
254
|
+
# slots for the count badge and the collapse icon) so the header keeps its
|
|
255
|
+
# exact size when the loaded body swaps in — nothing shifts or grows. The
|
|
256
|
+
# reserved slots are invisible skeletons; the real count/icon replace them
|
|
257
|
+
# on load without a reflow.
|
|
174
258
|
def render_column_header(column)
|
|
175
|
-
div(class: "px-3 py-2 flex items-center justify-between border-b border-[var(--pu-border)] bg-[var(--pu-surface)]") do
|
|
176
|
-
div(class: "flex items-center gap-2 min-w-0") do
|
|
259
|
+
div(class: "px-3 py-2 flex items-center justify-between gap-2 border-b border-[var(--pu-border)] bg-[var(--pu-surface)]") do
|
|
260
|
+
div(class: "flex items-center gap-2 min-w-0 flex-1") do
|
|
177
261
|
render_color_dot(column.color) if column.color
|
|
178
262
|
span(class: "font-semibold text-sm text-[var(--pu-text)] truncate") { plain column.label }
|
|
263
|
+
# The loaded header always shows a count now — a plain count, or a
|
|
264
|
+
# "count/limit" badge for wip columns — so reserve it either way,
|
|
265
|
+
# sized to the eventual text.
|
|
266
|
+
render_count_placeholder(column.wip ? "0/0" : "0")
|
|
179
267
|
end
|
|
268
|
+
render_icon_placeholder
|
|
180
269
|
end
|
|
181
270
|
end
|
|
182
271
|
|
|
272
|
+
# Invisible badge skeleton reserving the count badge's height and
|
|
273
|
+
# approximate width so the label doesn't reflow when the count appears.
|
|
274
|
+
def render_count_placeholder(text)
|
|
275
|
+
span(
|
|
276
|
+
class: "pu-badge pu-badge-neutral text-xs font-mono opacity-0",
|
|
277
|
+
aria_hidden: "true"
|
|
278
|
+
) { plain text }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Reserves the collapse toggle's footprint (the loaded header always has
|
|
282
|
+
# one on the right) so the row height and label width don't change on load.
|
|
283
|
+
def render_icon_placeholder
|
|
284
|
+
span(
|
|
285
|
+
class: "shrink-0 p-0.5 text-[var(--pu-text-subtle)]",
|
|
286
|
+
aria_hidden: "true"
|
|
287
|
+
) { plain "◀" }
|
|
288
|
+
end
|
|
289
|
+
|
|
183
290
|
# Builds the move URL template for the Stimulus drag controller.
|
|
184
291
|
# The collection path from the current request is used so engine
|
|
185
292
|
# mounting and path-scoped tenancy are automatically preserved.
|
|
@@ -27,7 +27,7 @@ module Plutonium
|
|
|
27
27
|
|
|
28
28
|
def html_attributes = {lang:, data_controller: "color-mode"}
|
|
29
29
|
|
|
30
|
-
def body_attributes = {class: "antialiased min-h-screen bg-[var(--pu-body)]"}
|
|
30
|
+
def body_attributes = {class: "antialiased min-h-screen bg-[var(--pu-body)] text-[var(--pu-text)]"}
|
|
31
31
|
|
|
32
32
|
def main_attributes = {class: "p-4 min-h-screen"}
|
|
33
33
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Options
|
|
6
|
+
# Shared detector for a `has_cents` money field, so the form and display
|
|
7
|
+
# inferred-type chains agree on what counts as currency (an explicit
|
|
8
|
+
# `as: :currency` is never needed for a `has_cents` attribute). Included
|
|
9
|
+
# into both `Form::Options::InferredTypes` and `Display::Options::InferredTypes`.
|
|
10
|
+
module HasCentsField
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Whether the field being rendered is a `has_cents` decimal accessor.
|
|
14
|
+
def has_cents_field?
|
|
15
|
+
klass = object.class
|
|
16
|
+
klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(key)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -33,7 +33,7 @@ module Plutonium
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def page_actions
|
|
36
|
-
super || current_definition.defined_actions.values.select { |a| a.resource_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
|
|
36
|
+
super || current_definition.defined_actions.values.select { |a| a.resource_action? && !a.kanban_drop? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def render_default_content
|
|
@@ -27,13 +27,23 @@ module Plutonium
|
|
|
27
27
|
description: page_description,
|
|
28
28
|
size: current_interactive_action.modal_size(current_definition)
|
|
29
29
|
) do
|
|
30
|
-
|
|
30
|
+
render_interactive_action_form
|
|
31
31
|
end
|
|
32
32
|
else
|
|
33
|
-
div(class: "pb-20") {
|
|
33
|
+
div(class: "pb-20") { render_interactive_action_form }
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Renders the interaction's form inside the modal/page chrome.
|
|
38
|
+
# Extracted as a seam so subclasses (e.g. the kanban drop-move page)
|
|
39
|
+
# can swap in a form that posts elsewhere and carries extra context
|
|
40
|
+
# without duplicating the modal chrome above.
|
|
41
|
+
def render_interactive_action_form
|
|
42
|
+
render partial(interactive_action_form_partial)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def interactive_action_form_partial = "interactive_action_form"
|
|
46
|
+
|
|
37
47
|
def page_type = :interactive_action_page
|
|
38
48
|
end
|
|
39
49
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Page
|
|
6
|
+
# Modal page shown when a card is dropped into a column that declares a
|
|
7
|
+
# `enter_interaction:`. It reuses the interactive-action modal chrome
|
|
8
|
+
# (title/description/modal mode all come from the enter_interaction's
|
|
9
|
+
# auto-registered record action) but renders a form that POSTs to the
|
|
10
|
+
# `kanban_move` member route instead of the interaction's own commit URL,
|
|
11
|
+
# carrying the move context (from_column/to_column/to_index) as hidden
|
|
12
|
+
# fields alongside the interaction's own inputs.
|
|
13
|
+
class KanbanMove < InteractiveAction
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def interactive_action_form_partial = "kanban_move_action_form"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -15,7 +15,7 @@ module Plutonium
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def page_actions
|
|
18
|
-
super || current_definition.defined_actions.values.select { |a| a.record_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
|
|
18
|
+
super || current_definition.defined_actions.values.select { |a| a.record_action? && !a.kanban_drop? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def render_default_content
|
|
@@ -48,7 +48,12 @@ module Plutonium
|
|
|
48
48
|
size: :lg,
|
|
49
49
|
open_full_url: request.path
|
|
50
50
|
) do
|
|
51
|
-
|
|
51
|
+
# The modal body owns no padding — content provides its own (the
|
|
52
|
+
# form uses this same padded, scrollable region). Without it the
|
|
53
|
+
# detail cards sit flush against the modal edges.
|
|
54
|
+
div(class: "flex-1 min-h-0 overflow-y-auto px-6 py-5") do
|
|
55
|
+
render partial("resource_details")
|
|
56
|
+
end
|
|
52
57
|
end
|
|
53
58
|
end
|
|
54
59
|
|
|
@@ -151,7 +151,7 @@ module Plutonium
|
|
|
151
151
|
policy = policy_for(record:)
|
|
152
152
|
|
|
153
153
|
actions = resource_definition.defined_actions
|
|
154
|
-
.select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
|
|
154
|
+
.select { |k, a| a.collection_record_action? && !a.kanban_drop? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
|
|
155
155
|
.values
|
|
156
156
|
|
|
157
157
|
primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
|
|
@@ -33,11 +33,44 @@ module Plutonium
|
|
|
33
33
|
input_options = @summary_inputs[name]&.dig(:options) || {}
|
|
34
34
|
field_options = input_options[:label] ? {label: input_options[:label]} : {}
|
|
35
35
|
|
|
36
|
+
# A currency input stages a plain decimal; the data snapshot carries no
|
|
37
|
+
# has_cents reflection, so inference would render it as a bare number.
|
|
38
|
+
# Force the currency display and thread the declared `unit:` so the
|
|
39
|
+
# recap reads "$1,234.56", matching the input's prefix.
|
|
40
|
+
if input_options[:as]&.to_sym == :currency
|
|
41
|
+
return render field(name, **field_options).wrapped { |f|
|
|
42
|
+
render f.send(:create_component, Plutonium::UI::Display::Components::Currency, :currency, unit: input_options[:unit])
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A choice input (select/radio_buttons) stages the raw value; its
|
|
47
|
+
# value -> label map lives only in `choices:`. Resolve it and render the
|
|
48
|
+
# label as a string, rather than inferring a plain-string tag off the
|
|
49
|
+
# raw value (which would show "female" instead of "Female").
|
|
50
|
+
if input_options[:choices]
|
|
51
|
+
label = resolve_choice_label(name, input_options)
|
|
52
|
+
return render field(name, value: label, **field_options).wrapped { |f| render f.string_tag }
|
|
53
|
+
end
|
|
54
|
+
|
|
36
55
|
render field(name, **field_options).wrapped do |f|
|
|
37
56
|
render instance_exec(f, &summary_tag_block(name))
|
|
38
57
|
end
|
|
39
58
|
end
|
|
40
59
|
|
|
60
|
+
# Map the raw value(s) to their choice label(s) using the SAME mapper the
|
|
61
|
+
# form's select uses, so summary labels always match the form. Falls back
|
|
62
|
+
# to the raw value for anything not in `choices:`; nil for an empty field
|
|
63
|
+
# (so the display renders its placeholder).
|
|
64
|
+
def resolve_choice_label(name, input_options)
|
|
65
|
+
mapper = Phlexi::Form::SimpleChoicesMapper.new(
|
|
66
|
+
input_options[:choices],
|
|
67
|
+
label_method: input_options[:label_method],
|
|
68
|
+
value_method: input_options[:value_method]
|
|
69
|
+
)
|
|
70
|
+
raw = Phlexi::Field::Support::Value.from(object, name)
|
|
71
|
+
Array(raw).map { |value| mapper[value] || value }.join(", ").presence
|
|
72
|
+
end
|
|
73
|
+
|
|
41
74
|
# Pick the display component the same way a resource display does — infer it
|
|
42
75
|
# from the value's TYPE (date, boolean, number, currency, …) instead of
|
|
43
76
|
# stringifying everything. The one override: an attachment field stages a
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radioactive-labs/plutonium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/js/core.js",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"postcss-import": "^16.1.1",
|
|
50
50
|
"tailwindcss": "^4.3.0",
|
|
51
51
|
"vitepress": "^1.6.4",
|
|
52
|
+
"vitepress-plugin-llms": "^1.13.2",
|
|
52
53
|
"vitepress-plugin-mermaid": "^2.0.17"
|
|
53
54
|
},
|
|
54
55
|
"scripts": {
|
|
@@ -59,8 +60,9 @@
|
|
|
59
60
|
"js:dev": "node esbuild.config.js --dev",
|
|
60
61
|
"css:prod": "postcss src/css/plutonium.entry.css -o app/assets/plutonium.css && postcss src/css/plutonium.entry.css -o src/dist/css/plutonium.css",
|
|
61
62
|
"js:prod": "node esbuild.config.js",
|
|
62
|
-
"docs:
|
|
63
|
-
"docs:
|
|
63
|
+
"docs:sync-skills": "node docs/.vitepress/sync-skills.mjs",
|
|
64
|
+
"docs:dev": "yarn docs:sync-skills && vitepress dev docs",
|
|
65
|
+
"docs:build": "yarn docs:sync-skills && vitepress build docs",
|
|
64
66
|
"docs:preview": "vitepress preview docs"
|
|
65
67
|
}
|
|
66
68
|
}
|
data/src/css/components.css
CHANGED
|
@@ -635,6 +635,11 @@ select.pu-input[multiple] option {
|
|
|
635
635
|
own pinned-right surface and intentionally does not opt in. */
|
|
636
636
|
.pu-dialog {
|
|
637
637
|
background-color: var(--pu-surface);
|
|
638
|
+
/* Pair the surface background with the theme text colour. The UA stylesheet
|
|
639
|
+
resets <dialog> color to CanvasText (black) and it does NOT inherit from
|
|
640
|
+
<body>, so without this any unstyled text inside a modal (e.g. a show page's
|
|
641
|
+
plain field values) renders black on the dark surface. */
|
|
642
|
+
color: var(--pu-text);
|
|
638
643
|
border: 1px solid var(--pu-border);
|
|
639
644
|
border-radius: var(--pu-radius-lg);
|
|
640
645
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="currency-input"
|
|
4
|
+
//
|
|
5
|
+
// Pads a currency input's left edge to exactly clear its overlaid unit prefix.
|
|
6
|
+
//
|
|
7
|
+
// The prefix ("$", "£", "GH₵", "USD", …) is absolutely positioned at the
|
|
8
|
+
// field's left edge; its width varies by symbol AND font, so any fixed padding
|
|
9
|
+
// is wrong for some currency — too little clips a wide prefix (digits collide
|
|
10
|
+
// with it, as with "GH₵"), too much wastes space on a bare "$". This measures
|
|
11
|
+
// the rendered prefix and sets padding-left to match, so the caret always sits
|
|
12
|
+
// just past the symbol regardless of currency or typeface.
|
|
13
|
+
//
|
|
14
|
+
// DOM contract:
|
|
15
|
+
// wrapper data-controller="currency-input"
|
|
16
|
+
// prefix data-currency-input-target="prefix" (the overlaid unit span)
|
|
17
|
+
// input data-currency-input-target="field" (the number input)
|
|
18
|
+
export default class extends Controller {
|
|
19
|
+
static targets = ["prefix", "field"]
|
|
20
|
+
// Space between the prefix and the first digit, in px.
|
|
21
|
+
static values = { gap: { type: Number, default: 6 } }
|
|
22
|
+
|
|
23
|
+
connect() {
|
|
24
|
+
this.#pad()
|
|
25
|
+
// Web fonts often load after connect; the fallback font's metrics differ,
|
|
26
|
+
// so re-measure once they're ready to correct the padding.
|
|
27
|
+
document.fonts.ready.then(() => this.#pad())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#pad() {
|
|
31
|
+
// Inline !important so it beats pu-input's own left padding (a plain class
|
|
32
|
+
// would lose the cascade — see the note this replaces in currency.rb).
|
|
33
|
+
this.fieldTarget.style.setProperty(
|
|
34
|
+
"padding-left",
|
|
35
|
+
`${this.prefixTarget.offsetWidth + this.gapValue}px`,
|
|
36
|
+
"important"
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|