plutonium 0.60.5 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +70 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +122 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +945 -286
- metadata +113 -2
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Kanban
|
|
6
|
+
# Board shell — renders one lazy turbo-frame per column.
|
|
7
|
+
#
|
|
8
|
+
# Each frame id is "kanban-col-<key>". Its src merges view=kanban and
|
|
9
|
+
# column=<key> into the current request URL so Task 6's column endpoint
|
|
10
|
+
# fills the body on demand. The frame already contains the column header
|
|
11
|
+
# (color dot + label) so the shell is meaningful while the body loads. The
|
|
12
|
+
# header deliberately omits a card-count badge: the shell has no card data,
|
|
13
|
+
# so a "0" would flash then vanish when Kanban::Column (which renders no
|
|
14
|
+
# count badge) replaces the frame body.
|
|
15
|
+
#
|
|
16
|
+
# The outer wrapper carries data-controller="kanban" for the Stimulus
|
|
17
|
+
# drag controller wired in Task 11.
|
|
18
|
+
#
|
|
19
|
+
# Realtime subscription: when board.realtime? is true,
|
|
20
|
+
# render_realtime_subscription is called before the board wrapper.
|
|
21
|
+
# Task 14 implements the broadcaster and fills in that method body.
|
|
22
|
+
class Resource < Plutonium::UI::Component::Base
|
|
23
|
+
include ColorDot
|
|
24
|
+
include Phlex::Rails::Helpers::TurboFrameTag
|
|
25
|
+
include Phlex::Rails::Helpers::TurboStreamFrom
|
|
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
|
+
|
|
49
|
+
attr_reader :board, :grouped_data, :resource_definition, :resource_fields,
|
|
50
|
+
:resource_class, :scoped_entity
|
|
51
|
+
|
|
52
|
+
def initialize(board:, grouped_data:, resource_definition:, resource_fields:,
|
|
53
|
+
resource_class: nil, scoped_entity: nil)
|
|
54
|
+
@board = board
|
|
55
|
+
@grouped_data = grouped_data
|
|
56
|
+
@resource_definition = resource_definition
|
|
57
|
+
# TODO (Tasks 6/10): resource_fields arrives ALREADY resolved — the
|
|
58
|
+
# column endpoint / index page is responsible for the
|
|
59
|
+
# `board.card_fields || definition.grid_fields` fallback before
|
|
60
|
+
# constructing this component. Kanban::Resource (and the Card it
|
|
61
|
+
# eventually builds) just receives the final field list and renders
|
|
62
|
+
# it; it does not resolve card_fields itself.
|
|
63
|
+
@resource_fields = resource_fields
|
|
64
|
+
# Used by render_realtime_subscription (Task 14) to scope the
|
|
65
|
+
# ActionCable stream to the correct tenant + resource.
|
|
66
|
+
@resource_class = resource_class
|
|
67
|
+
@scoped_entity = scoped_entity
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def view_template
|
|
71
|
+
render_realtime_subscription if board.realtime?
|
|
72
|
+
|
|
73
|
+
# Wrap in the filter-panel controller so the toolbar's Filter button
|
|
74
|
+
# can open the slideover — same structure as Grid/Table::Resource, so
|
|
75
|
+
# the board carries the shared view switcher (Table | Grid | Board),
|
|
76
|
+
# search, scopes, and filters rather than rendering bare columns.
|
|
77
|
+
div(data: filter_panel_controller_data) do
|
|
78
|
+
# Persistent append target for move-rejection toasts. It lives
|
|
79
|
+
# OUTSIDE the per-column turbo-frames so it survives the frame swaps
|
|
80
|
+
# a move triggers; toasts are position:fixed, so its location in the
|
|
81
|
+
# DOM has no layout effect. The move handler appends a flash toast
|
|
82
|
+
# here via turbo_stream when a drop is rejected (WIP / accepts /
|
|
83
|
+
# locked) so the snap-back is explained rather than silent.
|
|
84
|
+
div(id: "kanban-flash")
|
|
85
|
+
render_scopes_pills
|
|
86
|
+
render_toolbar
|
|
87
|
+
|
|
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}",
|
|
97
|
+
class: "pu-kanban-board flex gap-4 overflow-x-auto p-4 min-h-0",
|
|
98
|
+
data: {
|
|
99
|
+
turbo_permanent: true,
|
|
100
|
+
controller: "kanban",
|
|
101
|
+
# Stimulus value consumed by the drag controller to build the
|
|
102
|
+
# per-record move URL at drop time. The collection path comes from
|
|
103
|
+
# request.path so tenant / engine scoping is preserved automatically.
|
|
104
|
+
# Example: /admin/tasks/__ID__/kanban_move
|
|
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)
|
|
110
|
+
}
|
|
111
|
+
) do
|
|
112
|
+
grouped_data.each do |entry|
|
|
113
|
+
render_column_frame(entry[:column], collapsed: entry[:collapsed])
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
render_filter_slideover if current_query_object.filter_definitions.present?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def filter_panel_controller_data
|
|
124
|
+
{controller: "filter-panel"}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def render_scopes_pills
|
|
128
|
+
TableScopesPills() if current_query_object.scope_definitions.any?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The shared index toolbar — view switcher (Table | Grid | Board),
|
|
132
|
+
# search, and the filter button. `current_view: :kanban` keeps the
|
|
133
|
+
# Board segment highlighted.
|
|
134
|
+
def render_toolbar
|
|
135
|
+
TableToolbar(
|
|
136
|
+
query: current_query_object,
|
|
137
|
+
search_url: request.path,
|
|
138
|
+
search_value: params.dig(:q, :search) || params[:search],
|
|
139
|
+
views: resource_definition.defined_index_views,
|
|
140
|
+
current_view: :kanban,
|
|
141
|
+
view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
|
|
142
|
+
view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def render_filter_slideover
|
|
147
|
+
div(
|
|
148
|
+
class: "fixed inset-0 z-40 bg-black/40 opacity-0 pointer-events-none " \
|
|
149
|
+
"transition-opacity duration-200 " \
|
|
150
|
+
"data-[open]:opacity-100 data-[open]:pointer-events-auto",
|
|
151
|
+
data: {filter_panel_target: "backdrop", action: "click->filter-panel#close"}
|
|
152
|
+
)
|
|
153
|
+
aside(
|
|
154
|
+
class: "fixed top-0 right-0 bottom-0 z-50 w-full sm:w-[420px] max-w-full " \
|
|
155
|
+
"bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
|
|
156
|
+
"translate-x-full transition-transform duration-300 ease-out " \
|
|
157
|
+
"data-[open]:translate-x-0 " \
|
|
158
|
+
"flex flex-col",
|
|
159
|
+
role: "dialog",
|
|
160
|
+
aria: {label: "Filters", hidden: "true", modal: "true"},
|
|
161
|
+
data: {filter_panel_target: "panel"}
|
|
162
|
+
) do
|
|
163
|
+
render Plutonium::UI::Table::Components::FilterForm.new(
|
|
164
|
+
filter_form_values,
|
|
165
|
+
query_object: current_query_object,
|
|
166
|
+
search_url: request.path,
|
|
167
|
+
search_value: params.dig(:q, :search) || params[:search]
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def filter_form_values
|
|
173
|
+
raw = params[:q]
|
|
174
|
+
return {} unless raw
|
|
175
|
+
hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
|
|
176
|
+
hash.deep_symbolize_keys.except(:search, :scope, :sort_fields, :sort_directions)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Emits a <turbo-cable-stream-source> element that subscribes this page
|
|
180
|
+
# to the kanban board's tenant-scoped ActionCable stream.
|
|
181
|
+
#
|
|
182
|
+
# Only called when board.realtime? is true (gated by the caller in
|
|
183
|
+
# view_template). The stream name matches the one used by
|
|
184
|
+
# Plutonium::Kanban::Broadcaster#broadcast so move events reach exactly
|
|
185
|
+
# the right subscribers.
|
|
186
|
+
def render_realtime_subscription
|
|
187
|
+
turbo_stream_from(
|
|
188
|
+
*Plutonium::Kanban::Broadcaster.stream_name(
|
|
189
|
+
resource_class: resource_class,
|
|
190
|
+
scoped_entity: scoped_entity
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
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
|
+
}
|
|
205
|
+
attrs[:loading] = "lazy" if board.lazy?
|
|
206
|
+
|
|
207
|
+
turbo_frame_tag(column_frame_id(column), **attrs) do
|
|
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
|
|
225
|
+
render_column_header(column)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
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.
|
|
258
|
+
def render_column_header(column)
|
|
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
|
|
261
|
+
render_color_dot(column.color) if column.color
|
|
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")
|
|
267
|
+
end
|
|
268
|
+
render_icon_placeholder
|
|
269
|
+
end
|
|
270
|
+
end
|
|
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
|
+
|
|
290
|
+
# Builds the move URL template for the Stimulus drag controller.
|
|
291
|
+
# The collection path from the current request is used so engine
|
|
292
|
+
# mounting and path-scoped tenancy are automatically preserved.
|
|
293
|
+
# The literal string "__ID__" is a placeholder; the JS controller
|
|
294
|
+
# replaces it with the dragged card's record id at drop time.
|
|
295
|
+
def kanban_move_url_template
|
|
296
|
+
base = request.path.delete_suffix("/")
|
|
297
|
+
"#{base}/__ID__/kanban_move"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Returns the turbo-frame element id for a column.
|
|
301
|
+
# Plain id — not turbo_scoped_dom_id which is for modal-frame scoping.
|
|
302
|
+
def column_frame_id(column)
|
|
303
|
+
"kanban-col-#{column.key}"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Builds the frame src URL from the current request path + merged params.
|
|
307
|
+
# Merges view=kanban and column=<key> so the column endpoint (Task 6)
|
|
308
|
+
# knows which column to render without requiring an explicit route param.
|
|
309
|
+
def column_frame_src(column)
|
|
310
|
+
merged = request.query_parameters.merge(
|
|
311
|
+
"view" => "kanban",
|
|
312
|
+
"column" => column.key.to_s
|
|
313
|
+
)
|
|
314
|
+
"#{request.path}?#{merged.to_query}"
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -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
|
|
|
@@ -61,11 +61,17 @@ module Plutonium
|
|
|
61
61
|
)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# The classic shell always renders its sidebar; the modern family
|
|
65
|
+
# renders the icon rail only when the rail is active. Mirrors the
|
|
66
|
+
# classic special-casing in main_attributes/html_attributes, whose
|
|
67
|
+
# offsets are likewise rail-independent for :classic.
|
|
68
|
+
def render_sidebar? = shell == :classic || rail?
|
|
69
|
+
|
|
64
70
|
def render_before_main
|
|
65
71
|
super
|
|
66
72
|
|
|
67
73
|
render partial("resource_header")
|
|
68
|
-
render partial("resource_sidebar") if
|
|
74
|
+
render partial("resource_sidebar") if render_sidebar?
|
|
69
75
|
end
|
|
70
76
|
end
|
|
71
77
|
end
|
|
@@ -24,10 +24,14 @@ module Plutonium
|
|
|
24
24
|
(mode == :centered) ? Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def initialize(title: nil, description: nil, size: :md)
|
|
27
|
+
def initialize(title: nil, description: nil, size: :md, open_full_url: nil)
|
|
28
28
|
@title = title
|
|
29
29
|
@description = description
|
|
30
30
|
@size = size
|
|
31
|
+
# When set, the header shows an "open full page" affordance that opens
|
|
32
|
+
# this URL in a new tab as a standalone page (no modal frame). The show
|
|
33
|
+
# modal uses it so a user can pop the record out to its full page.
|
|
34
|
+
@open_full_url = open_full_url
|
|
31
35
|
validate_size!
|
|
32
36
|
end
|
|
33
37
|
|
|
@@ -106,7 +110,30 @@ module Plutonium
|
|
|
106
110
|
p(id: description_id, class: "mt-1 text-sm text-[var(--pu-text-muted)]") { @description }
|
|
107
111
|
end
|
|
108
112
|
end
|
|
109
|
-
|
|
113
|
+
# The corner-hugging offset lives on the GROUP (not the individual
|
|
114
|
+
# buttons) so the buttons' own margins don't collapse the gap when
|
|
115
|
+
# there are two of them (open-full + close).
|
|
116
|
+
div(class: "flex items-center gap-1 shrink-0 -mt-1.5 -mr-1.5") do
|
|
117
|
+
render_open_full_link if @open_full_url
|
|
118
|
+
render_close_button
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Opens the modal's content as a standalone full page in a new tab.
|
|
124
|
+
# target=_blank gives a fresh browsing context with no Turbo-Frame
|
|
125
|
+
# header, so the destination renders full-page rather than re-entering
|
|
126
|
+
# the modal frame.
|
|
127
|
+
def render_open_full_link
|
|
128
|
+
a(
|
|
129
|
+
href: @open_full_url,
|
|
130
|
+
target: "_blank",
|
|
131
|
+
rel: "noopener",
|
|
132
|
+
class: "p-1.5 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-md transition-colors",
|
|
133
|
+
"aria-label": "Open full page in a new tab",
|
|
134
|
+
title: "Open full page"
|
|
135
|
+
) do
|
|
136
|
+
render Phlex::TablerIcons::ArrowsDiagonal.new(class: "w-5 h-5")
|
|
110
137
|
end
|
|
111
138
|
end
|
|
112
139
|
|
|
@@ -116,7 +143,7 @@ module Plutonium
|
|
|
116
143
|
else
|
|
117
144
|
button(
|
|
118
145
|
type: "button",
|
|
119
|
-
class: "p-1.5
|
|
146
|
+
class: "p-1.5 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-md transition-colors",
|
|
120
147
|
data: {action: "remote-modal#close"},
|
|
121
148
|
"aria-label": "Close dialog"
|
|
122
149
|
) do
|
|
@@ -22,16 +22,19 @@ module Plutonium
|
|
|
22
22
|
# Surface (bg, border, radius, backdrop) lives in `.pu-dialog` so
|
|
23
23
|
# the centered modal, dirty-form-guard prompt, and Turbo confirm
|
|
24
24
|
# can't drift on design tokens. The remaining utilities are
|
|
25
|
-
# positioning, sizing, and the open/close
|
|
25
|
+
# positioning, sizing, and the open/close opacity+scale animation —
|
|
26
26
|
# driven by [data-open] (set on the frame after showModal() by
|
|
27
27
|
# remote_modal_controller); avoids the @starting-style spec dance.
|
|
28
|
+
# The transition must name `scale` (not `transform`): Tailwind v4's
|
|
29
|
+
# `scale-*` sets the discrete `scale` CSS property, so a
|
|
30
|
+
# `transition-[...,transform]` would leave the scale pop un-animated.
|
|
28
31
|
def base_dialog_classes
|
|
29
32
|
"pu-dialog " \
|
|
30
33
|
"top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
|
|
31
34
|
"max-h-[80vh] " \
|
|
32
35
|
"open:flex flex-col p-0 " \
|
|
33
36
|
"opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
|
|
34
|
-
"transition-[opacity,
|
|
37
|
+
"transition-[opacity,scale] duration-200 ease-out"
|
|
35
38
|
end
|
|
36
39
|
end
|
|
37
40
|
end
|
|
@@ -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,11 +33,12 @@ 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
|
|
40
40
|
case selected_view
|
|
41
|
+
when :kanban then render partial("resource_kanban")
|
|
41
42
|
when :grid then render partial("resource_grid")
|
|
42
43
|
else render partial("resource_table")
|
|
43
44
|
end
|
|
@@ -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,10 +15,16 @@ 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
|
|
22
|
+
# When the show request arrives via a modal frame (e.g. a kanban card
|
|
23
|
+
# with `show_in :modal`), wrap the details in the modal chrome — the
|
|
24
|
+
# same path New/Edit use for their forms. The aside is dropped in the
|
|
25
|
+
# modal: a slideover/centered dialog has no room for a side rail.
|
|
26
|
+
return render_modal_details if in_modal?
|
|
27
|
+
|
|
22
28
|
if aside_present?
|
|
23
29
|
div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_240px] gap-6") do
|
|
24
30
|
div { render partial("resource_details") }
|
|
@@ -29,6 +35,28 @@ module Plutonium
|
|
|
29
35
|
end
|
|
30
36
|
end
|
|
31
37
|
|
|
38
|
+
# The show page is ALWAYS a centered dialog when shown in a modal —
|
|
39
|
+
# deliberately not the definition's modal_mode (which styles :new/:edit
|
|
40
|
+
# as a slideover by default). A centered dialog reads as a focused
|
|
41
|
+
# "detail card" and leaves a launching board/table visible around it.
|
|
42
|
+
# `open_full_url` is the current show URL (request.path is the show
|
|
43
|
+
# route here), letting the user pop the record out to its full page.
|
|
44
|
+
def render_modal_details
|
|
45
|
+
render Plutonium::UI::Modal::Centered.new(
|
|
46
|
+
title: page_title,
|
|
47
|
+
description: page_description,
|
|
48
|
+
size: :lg,
|
|
49
|
+
open_full_url: request.path
|
|
50
|
+
) do
|
|
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
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
32
60
|
def page_type = :show_page
|
|
33
61
|
end
|
|
34
62
|
end
|