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,334 @@
|
|
|
1
|
+
# Kanban DSL Reference
|
|
2
|
+
|
|
3
|
+
::: warning Experimental
|
|
4
|
+
Kanban boards are experimental — the DSL and behavior may change in a future release.
|
|
5
|
+
:::
|
|
6
|
+
|
|
7
|
+
Complete reference for the `kanban do…end` block declared inside a resource Definition.
|
|
8
|
+
|
|
9
|
+
## Entry point
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class PostDefinition < ResourceDefinition
|
|
13
|
+
kanban do
|
|
14
|
+
# board-level options + column declarations
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Calling `kanban` automatically adds `:kanban` to `defined_index_views`. To set it as the default view:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
default_index_view :kanban
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To make it the only view:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
index_views :kanban
|
|
29
|
+
kanban do ... end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Board-level options
|
|
35
|
+
|
|
36
|
+
### `per_column(n)`
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
per_column 25
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Maximum cards rendered per column. When the column total exceeds `n`, a `+N more` footer appears. Column actions with `on: :all` still operate against the full column set; `on: :visible` is capped to the rendered subset.
|
|
43
|
+
|
|
44
|
+
Default: `nil` (unlimited).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### `position_on`
|
|
49
|
+
|
|
50
|
+
Controls how card positions are persisted after a drag-and-drop. Three modes:
|
|
51
|
+
|
|
52
|
+
#### Mode A — delegate to `Plutonium::Positioning` (default)
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# Default: uses :position attribute
|
|
56
|
+
# (no explicit call needed if the model includes Plutonium::Positioning)
|
|
57
|
+
|
|
58
|
+
# Custom attribute name:
|
|
59
|
+
position_on :sort_order
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requires the model to:
|
|
63
|
+
1. `include Plutonium::Positioning`
|
|
64
|
+
2. Call `positioned_on :position, scope: :grouping_attribute`
|
|
65
|
+
3. Have a `decimal` column for the position attribute — add it with the `t.position` migration helper (a tuned `decimal(16,8)`) — see [Positioning › Migration](/reference/kanban/positioning#migration)
|
|
66
|
+
|
|
67
|
+
On drop, calls `record.reposition!(prev_record:, next_record:)` which computes the decimal midpoint and updates the record.
|
|
68
|
+
|
|
69
|
+
#### Mode B — BYO block
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
position_on :sort_order do |move|
|
|
73
|
+
# move is a Plutonium::Kanban::Positioning::Move value object:
|
|
74
|
+
# move.record — the dropped ActiveRecord record
|
|
75
|
+
# move.column — destination column key (Symbol)
|
|
76
|
+
# move.prev — record immediately before the insertion slot, or nil
|
|
77
|
+
# move.next — record immediately after the insertion slot, or nil
|
|
78
|
+
# move.index — 0-based insertion index within the destination column
|
|
79
|
+
move.record.update!(sort_order: compute_position(move.prev, move.next))
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The block is evaluated via `call` (not `instance_exec`) — it is a plain Ruby proc/lambda.
|
|
84
|
+
|
|
85
|
+
Plutonium still orders column cards by `sort_order` (the first argument); your block is responsible only for persisting the new value.
|
|
86
|
+
|
|
87
|
+
#### Mode C — disabled
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
position_on false
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
No ordering is applied. Cards render in the relation's natural order. On drop, only `on_enter` fires (if set); the position attribute is never touched.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### `realtime(v = true)` {#realtime}
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
realtime true
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Enables ActionCable broadcasting after every successful move. After a drop, Plutonium pushes the refreshed column frames to all viewers subscribed to this board's stream.
|
|
104
|
+
|
|
105
|
+
**Stream name format:**
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
["kanban", "<tenant_gid_or_global>", "<ResourceClass.name>"]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- Tenant-scoped portals use the entity's Global ID parameter as the second segment.
|
|
112
|
+
- Portals without entity scoping use the literal string `"global"`.
|
|
113
|
+
|
|
114
|
+
Two viewers share a stream only if they have the same resource class **and** the same scoped entity — cross-tenant leakage is impossible by construction.
|
|
115
|
+
|
|
116
|
+
Requires `turbo-rails` + ActionCable (gems), a cable adapter in `config/cable.yml` (Redis/Solid Cable in multi-process production), ActionCable mounted at `/cable`, **and** an ActionCable client loaded in your app's JavaScript. Plutonium's bundle ships `@hotwired/turbo` only — without `@hotwired/turbo-rails` (or `@rails/actioncable`) in your pack, the `<turbo-cable-stream-source>` never connects and other viewers won't update. Server-side broadcasting works regardless; this is purely the client subscription. See the [guide's Realtime setup](/guides/kanban#setup-required-for-realtime-to-actually-update-other-viewers).
|
|
117
|
+
|
|
118
|
+
Default: `false`.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### `lazy(v = true)`
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
lazy false # eager-load all columns on initial page request
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
When `true` (the default), each column is a Turbo Frame that loads its card list on demand (lazy loading). The frame loads when it enters the viewport.
|
|
129
|
+
|
|
130
|
+
When `false`, all column frames are loaded in the initial page request (one HTTP request per column).
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
### `show_in(mode)` {#show_in}
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
show_in :modal # open a card's show page in a centered modal dialog
|
|
138
|
+
show_in :page # navigate the whole page to the show route
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Overrides — **for this board** — where clicking a card opens the record's show page:
|
|
142
|
+
|
|
143
|
+
- `:modal` — the card's show link targets the layout's `remote_modal` frame, so the show page renders in a **centered** dialog. (Show is always centered — deliberately not the definition's `modal_mode`, which styles `new`/`edit`.)
|
|
144
|
+
- `:page` — the card's show link targets `_top`, navigating the whole page to the show route.
|
|
145
|
+
|
|
146
|
+
When `show_in` is **not** set on the board, the board inherits the definition's [`show_in`](/reference/resource/definition#show_in) (which itself defaults to `:page`). So to open cards in a modal you can set it on the board, or once on the definition (which also covers the table and grid views).
|
|
147
|
+
|
|
148
|
+
Either mode escapes the column's lazy turbo-frame — `:page` replaces the whole page, and the `remote_modal` frame lives in the layout (resolved document-wide), so it opens outside the column. No per-card configuration is needed; the show page detects the modal frame (`in_modal?`) and wraps its details in the centered modal chrome. From inside the modal, an expand icon (or ⌘/Ctrl-click on the card) opens the full page in a new tab.
|
|
149
|
+
|
|
150
|
+
An unknown mode raises `ArgumentError`.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
### `card_fields(**slots)`
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
card_fields(
|
|
158
|
+
header: :title,
|
|
159
|
+
subheader: :assignee,
|
|
160
|
+
meta: [:due_date, :priority]
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Overrides the slot layout for every kanban card on this board, using the same slot names as `grid_fields` (`:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer`). The kanban card renderer resolves its slots as `card_fields || definition.grid_fields`, so a board-level `card_fields` takes precedence over the resource's `grid_fields`.
|
|
165
|
+
|
|
166
|
+
When `card_fields` is not set, cards fall back to the resource definition's `grid_fields`. If neither is declared, the card renders the default header-only layout.
|
|
167
|
+
|
|
168
|
+
The `meta` slot renders each field as a colored badge, and formats values by type before badging: a `has_cents` field renders as currency, a `belongs_to` association renders as its label (not an object inspect), and everything else is humanized — with status-like enums (`active`, `pending`, `published`…) resolving to a semantic color. The badge color is deterministic per value, so a given status is the same color on every card.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Static columns
|
|
173
|
+
|
|
174
|
+
Declare columns at class-load time:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
kanban do
|
|
178
|
+
column :todo,
|
|
179
|
+
label: "To Do",
|
|
180
|
+
color: :blue,
|
|
181
|
+
scope: -> { where(status: "todo") },
|
|
182
|
+
on_enter: ->(r) { r.update!(status: "todo") },
|
|
183
|
+
role: :backlog
|
|
184
|
+
|
|
185
|
+
column :done,
|
|
186
|
+
scope: -> { where(status: "done") },
|
|
187
|
+
on_enter: :mark_done!,
|
|
188
|
+
accepts: [:doing],
|
|
189
|
+
role: :done do
|
|
190
|
+
action :archive_all, interaction: ArchiveTasksInteraction, on: :all
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Column options
|
|
196
|
+
|
|
197
|
+
| Option | Type | Default | Description |
|
|
198
|
+
|--------|------|---------|-------------|
|
|
199
|
+
| `label:` | String | `key.to_s.titleize` | Column header label |
|
|
200
|
+
| `color:` | Symbol or String | `nil` | Header color dot. Named colors: `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`. Raw CSS string also accepted |
|
|
201
|
+
| `scope:` | Symbol or Proc | `nil` | Relation filter for this column. **Symbol** → `relation.public_send(sym)` (named AR scope). **Proc** → 0-arg lambda called via `instance_exec` on the relation, e.g. `-> { where(status: "todo") }` |
|
|
202
|
+
| `on_enter:` | Symbol or Proc | `nil` | Fired when a card is dropped into this column. **Symbol** → `record.public_send(sym)`. **Proc** → 1-arg lambda `->(record) { … }` where `self` inside the block is the view context (giving access to `current_user`, helpers, etc.). The callback may assign attributes in memory (`r.status = :done`) or call `update!` directly; if the record has unsaved changes after `on_enter` returns the controller saves it automatically. |
|
|
203
|
+
| `on_exit:` | Symbol or Proc | `nil` | The source-side counterpart to `on_enter:`, fired when a card **leaves** this column on a cross-column move. Same dispatch (**Symbol** → `record.public_send`; **Proc** → 1-arg lambda, `self` = view context). Runs **before** the destination's `on_enter`, inside the same move transaction, so it sees the pre-move state and rolls back if the move fails. Use it for source-tied side effects the destination can't own (stop a timer, release a slot). Fires only on a drag-move through `kanban_move` — not on destroy, a programmatic status change, or quick-add. Skipped on same-column reorders. |
|
|
204
|
+
| `enter_interaction:` | Class | `nil` | A **record-scoped** interaction class (declares `attribute :resource`) run when a card is dropped **into** this column from another column. Opens the interaction's form as a modal to collect input, then commits `on_enter` + the interaction + repositioning atomically. Auto-registered as a hidden record action under a column-scoped key (`:<column>_enter_interaction`), authorized by `kanban_move?` (no policy method of its own). See [enter_interaction](#drop-interaction) below |
|
|
205
|
+
| `role:` | `:backlog`, `:done`, `:lost` | `nil` | Applies a preset (see below) |
|
|
206
|
+
| `collapsed:` | Boolean | `false` | Column starts collapsed (a thin strip with the label rotated). The Stimulus controller persists the toggled state to `localStorage` (key: `pu-kanban:<path>:<column-key>:collapsed`) so the user preference survives page reloads; this DSL value sets the server-rendered initial state only. |
|
|
207
|
+
| `add:` | Boolean | `false` | Show a `+ Add` quick-add button |
|
|
208
|
+
| `accepts:` | `true`, `false`, or Array | `true` | Drop policy — **structural topology only**. `true` accepts any source column. `false` rejects all drops (display-only column). An Array of column key symbols accepts only those sources. This is client-hintable: the value is emitted as `data-kanban-accepts` so the drag UI greys out disallowed source columns before the drop. **Record- or user-conditional rules do not belong here** — put them in `kanban_move?`, which sees the record and the `from`/`to` columns (see [Authorization](./authorization)). Passing a `Proc` raises `ArgumentError`. |
|
|
209
|
+
| `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
|
|
210
|
+
| `wip:` | Integer | `nil` | WIP limit. Reject cross-column drops when `dest_count + 1 > wip`. Has no effect on same-column reordering |
|
|
211
|
+
|
|
212
|
+
::: warning Renamed: `on_drop:` → `on_enter:`, `drop_interaction:` → `enter_interaction:`
|
|
213
|
+
The older `on_drop:` and `drop_interaction:` option names have been **renamed** to `on_enter:` and `enter_interaction:` (a card *entering* a column is the event, independent of how it got there).
|
|
214
|
+
|
|
215
|
+
The old names are **deprecated aliases**:
|
|
216
|
+
- In **development and test** they raise an `ArgumentError` so the rename is caught before release.
|
|
217
|
+
- In **deployed environments** (production, staging) they log a deprecation warning and map onto the new option, so an in-flight deployment keeps working across the upgrade.
|
|
218
|
+
|
|
219
|
+
If both the old and new name are given, the new one wins. Update your definitions to `on_enter:` / `enter_interaction:` at your earliest convenience — a future release will drop the aliases entirely.
|
|
220
|
+
:::
|
|
221
|
+
|
|
222
|
+
### Role presets
|
|
223
|
+
|
|
224
|
+
| Role | Equivalent options |
|
|
225
|
+
|------|--------------------|
|
|
226
|
+
| `:backlog` | `add: true` |
|
|
227
|
+
| `:done` | `color: :green, collapsed: true` |
|
|
228
|
+
| `:lost` | `color: :red, collapsed: true` |
|
|
229
|
+
|
|
230
|
+
`:done` and `:lost` are the two terminal roles — both collapsed by default, the
|
|
231
|
+
colour signalling the outcome (`:done` = positive close, `:lost` = negative
|
|
232
|
+
close). Use them as the won/lost pair in pipelines (leads, deals, tickets).
|
|
233
|
+
|
|
234
|
+
Explicitly passed options override the preset. Unknown role values raise `ArgumentError`.
|
|
235
|
+
|
|
236
|
+
### `enter_interaction:` {#drop-interaction}
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
column :lost,
|
|
240
|
+
scope: -> { where(status: "lost") },
|
|
241
|
+
enter_interaction: MarkLostInteraction
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Runs an authorization-aware, input-collecting interaction when a card is dropped **into** this column from another column.
|
|
245
|
+
|
|
246
|
+
- **Must be a record-scoped interaction** — the class declares `attribute :resource` (singular) and acts on the one dropped card. A `resources`-plural (bulk) interaction is not valid here; that shape is for [column actions](#column-actions).
|
|
247
|
+
- **Auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`) — unique by construction, so two columns can reuse the same interaction class without colliding. Hidden = it does not render as an action button on the show page, table rows, or grid cards; it is reachable only by a drop.
|
|
248
|
+
- **Authorized by `kanban_move?` alone** — the interaction has **no policy method of its own**. The move (and therefore the interaction) is gated by the single `kanban_move?` predicate, which can gate this specific transition via its `to` column context (e.g. `def kanban_move? = kanban_to&.key == :lost ? user.manager? : super`). See [Authorization](./authorization).
|
|
249
|
+
- **Move flow only.** Dropping cross-column opens the interaction's form as a modal; on submit `on_enter` + the interaction + repositioning commit in **one atomic transaction**. Validation failure rolls the whole transaction back (membership write included) and re-renders the modal with errors — nothing persists. Same-column reorders run positioning only (neither `on_enter` nor the interaction fires).
|
|
250
|
+
- **Quick-add (`+ Add`)** applies `on_enter` + positioning **post-create** — the record is created first (needs a grouping-column default), then `on_enter` places it in the column; the `enter_interaction` is not involved.
|
|
251
|
+
- **Author contract** — when a column declares both, `on_enter` owns the membership attribute (e.g. `status`) and the interaction owns the extras (reason, mail, audit). If the interaction also writes the membership attribute it must set the same value `on_enter` does (idempotent). With no `on_enter`, the interaction owns everything.
|
|
252
|
+
- **Success response limitation** — the interaction's success **message** (`.with_message`) surfaces as a toast, but a custom success *response* (`with_redirect_response`, `with_file_response`, …) is **not** honored on the drop path; the board just re-renders and closes the modal.
|
|
253
|
+
|
|
254
|
+
See the [guide's Interaction on drop section](/guides/kanban#interaction-on-drop) for a full worked example.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Dynamic columns
|
|
259
|
+
|
|
260
|
+
Use `columns do…end` when the column list depends on the current request:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
kanban do
|
|
264
|
+
columns do
|
|
265
|
+
# `self` is the view context — current_user, params, and helpers all work.
|
|
266
|
+
current_user.visible_statuses.map do |status|
|
|
267
|
+
Plutonium::Kanban::Column.new(
|
|
268
|
+
:"status_#{status.id}",
|
|
269
|
+
label: status.name,
|
|
270
|
+
color: status.color_symbol,
|
|
271
|
+
scope: -> { where(status_id: status.id) },
|
|
272
|
+
on_enter: ->(r) { r.update!(status_id: status.id) }
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The block is evaluated at request time. You can mix static pre-declared columns with a dynamic block: if both are present, the `columns` block takes precedence (the board is considered dynamic).
|
|
280
|
+
|
|
281
|
+
::: warning Column action registration for dynamic boards
|
|
282
|
+
Column actions declared inside a `columns do…end` block **cannot be auto-registered** at class-load time. Register the interaction as a top-level definition `action` as well:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
class TaskDefinition < ResourceDefinition
|
|
286
|
+
action :archive_column_tasks, interaction: ArchiveTasksInteraction
|
|
287
|
+
|
|
288
|
+
kanban do
|
|
289
|
+
columns do
|
|
290
|
+
build_status_columns.each do |col|
|
|
291
|
+
col.action :archive_column_tasks, interaction: ArchiveTasksInteraction, on: :all
|
|
292
|
+
col
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**`enter_interaction:` is not supported on dynamic boards.** Unlike a column action, its hidden action can't be registered manually — the key is column-scoped and internal — and the static registration pass has no columns to see. A drop into a dynamic column that declares `enter_interaction:` is rejected with a snap-back (it does not crash). Use a static board when a column needs an `enter_interaction:`.
|
|
300
|
+
:::
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Column actions
|
|
305
|
+
|
|
306
|
+
Declare actions inside a column `do…end` block:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
column :done,
|
|
310
|
+
scope: -> { where(status: "done") },
|
|
311
|
+
on_enter: :mark_done! do
|
|
312
|
+
|
|
313
|
+
action :archive_all,
|
|
314
|
+
interaction: ArchiveTasksInteraction,
|
|
315
|
+
on: :all,
|
|
316
|
+
label: "Archive all",
|
|
317
|
+
icon: Phlex::TablerIcons::Archive,
|
|
318
|
+
confirmation: "Archive all done tasks?"
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Action options
|
|
323
|
+
|
|
324
|
+
| Option | Type | Required | Description |
|
|
325
|
+
|--------|------|----------|-------------|
|
|
326
|
+
| `interaction:` | Class | Yes | An interaction class. Must have `attribute :resources` (plural) — it runs as a bulk action |
|
|
327
|
+
| `on:` | `:all` or `:visible` | No (default `:all`) | `:all` passes IDs of all column cards (ignoring `per_column`). `:visible` passes only the rendered, capped subset |
|
|
328
|
+
| `label:` | String | No | Button text. Defaults to `key.to_s.humanize` |
|
|
329
|
+
| `icon:` | Phlex icon class | No | Icon rendered before the label |
|
|
330
|
+
| `confirmation:` | String | No | Browser `confirm()` message shown before the action fires |
|
|
331
|
+
|
|
332
|
+
Column actions are rendered as small buttons in the column header. They open the standard interactive-action modal with full authorization, form rendering, and success/failure handling.
|
|
333
|
+
|
|
334
|
+
**Auto-registration:** For static columns, the `kanban` DSL automatically calls `action(key, interaction:, …)` at definition class-load time so the bulk route resolves. For dynamic `columns do…end` boards you must register the interaction manually (see warning above).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Kanban Reference
|
|
2
|
+
|
|
3
|
+
Reference documentation for the Plutonium kanban board feature.
|
|
4
|
+
|
|
5
|
+
## In this section
|
|
6
|
+
|
|
7
|
+
| Page | What it covers |
|
|
8
|
+
|------|---------------|
|
|
9
|
+
| [DSL](/reference/kanban/dsl) | Complete `kanban do…end` DSL — board options, columns, column actions, static vs. dynamic, lazy loading, realtime |
|
|
10
|
+
| [Positioning](/reference/kanban/positioning) | `Plutonium::Positioning` concern, `positioned_on`, `position_on` modes, `reposition!`, rebalancing |
|
|
11
|
+
| [Authorization](/reference/kanban/authorization) | `kanban_move?` policy predicate, read-only fallback, separating move rights from edit rights |
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# app/definitions/task_definition.rb
|
|
17
|
+
class TaskDefinition < ResourceDefinition
|
|
18
|
+
kanban do
|
|
19
|
+
per_column 25
|
|
20
|
+
|
|
21
|
+
column :todo,
|
|
22
|
+
scope: -> { where(status: "todo") },
|
|
23
|
+
on_enter: ->(r) { r.update!(status: "todo") },
|
|
24
|
+
role: :backlog
|
|
25
|
+
|
|
26
|
+
column :doing,
|
|
27
|
+
scope: -> { where(status: "doing") },
|
|
28
|
+
on_enter: ->(r) { r.update!(status: "doing") },
|
|
29
|
+
wip: 3
|
|
30
|
+
|
|
31
|
+
column :done,
|
|
32
|
+
scope: -> { where(status: "done") },
|
|
33
|
+
on_enter: :mark_done!,
|
|
34
|
+
accepts: [:doing],
|
|
35
|
+
role: :done
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
See the [Kanban guide](/guides/kanban) for a full walkthrough including model setup and migrations.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Kanban Positioning
|
|
2
|
+
|
|
3
|
+
Plutonium uses **decimal fractional positioning** for kanban card ordering. A drop writes a single decimal position (the midpoint between its neighbors), so the common case touches exactly one row — no bulk renumbering. The one exception is rare **rebalancing**: when the same slot has been subdivided ~20 times and the gap between two neighbors shrinks below `1e-6`, Plutonium renumbers that one scope group back to clean integers before inserting (see [Gap exhaustion](#rebalancing)).
|
|
4
|
+
|
|
5
|
+
## `Plutonium::Positioning` concern
|
|
6
|
+
|
|
7
|
+
Include this concern in any model you want to position:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class Task < ApplicationRecord
|
|
11
|
+
include Plutonium::Positioning
|
|
12
|
+
|
|
13
|
+
positioned_on :position, scope: :status
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### `positioned_on(column = :position, scope: nil)`
|
|
18
|
+
|
|
19
|
+
Configures positional ordering for the model.
|
|
20
|
+
|
|
21
|
+
| Argument | Description |
|
|
22
|
+
|----------|-------------|
|
|
23
|
+
| `column` | The `decimal` database column that stores positions. Default: `:position` |
|
|
24
|
+
| `scope:` | Group positions by this attribute. Records with different scope values are ordered independently. `nil` = single global ordering across all rows |
|
|
25
|
+
|
|
26
|
+
After calling `positioned_on`, the model gets:
|
|
27
|
+
- A `before_create` callback that assigns the next position in the scope group (appends to end).
|
|
28
|
+
- A `reposition!(prev_record:, next_record:)` instance method.
|
|
29
|
+
- A `backfill_positions!(order: :created_at)` class method.
|
|
30
|
+
|
|
31
|
+
### Migration
|
|
32
|
+
|
|
33
|
+
Use the **`t.position`** helper — it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't get the scale wrong:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
create_table :tasks do |t|
|
|
37
|
+
t.string :status, null: false, default: "todo"
|
|
38
|
+
t.position # decimal :position, precision: 16, scale: 8
|
|
39
|
+
t.timestamps
|
|
40
|
+
end
|
|
41
|
+
add_index :tasks, [:status, :position] # match your scope attribute
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Adding the column to an existing table works the same way in a `change_table` block:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class AddPositionToTasks < ActiveRecord::Migration[8.1]
|
|
48
|
+
def change
|
|
49
|
+
change_table(:tasks) { |t| t.position }
|
|
50
|
+
add_index :tasks, [:status, :position]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`t.position` accepts a custom column name and any `column` options:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
t.position :sort_order # custom name
|
|
59
|
+
t.position :position, index: true # also add a single-column index
|
|
60
|
+
t.position :position, scale: 10 # override precision/scale
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
::: tip Why the helper picks `scale: 8`
|
|
64
|
+
If you write the column by hand, give it at least **two more decimal places than `EPSILON` (`1e-6`)** — i.e. `scale: 8` or higher. Rebalancing triggers when a gap drops below `1e-6`, so a column that can store smaller values still has room to write the final midpoint cleanly. A `scale: 6` column has no headroom: the last subdivision before a rebalance can round to a neighbor and momentarily collide. `t.position` defaults to `scale: 8`, which is safe.
|
|
65
|
+
:::
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## `reposition!(prev_record:, next_record:)`
|
|
70
|
+
|
|
71
|
+
Moves a record so it sits between `prev_record` and `next_record` in its scope group. Pass `nil` for an end to prepend or append.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
task.reposition!(prev_record: card_a, next_record: card_b)
|
|
75
|
+
task.reposition!(prev_record: nil, next_record: first_card) # prepend
|
|
76
|
+
task.reposition!(prev_record: last_card, next_record: nil) # append
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Arithmetic:**
|
|
80
|
+
- Both nil → `0.0` (first item in empty group)
|
|
81
|
+
- Only `prev_record` → `prev.position + 1` (append)
|
|
82
|
+
- Only `next_record` → `next.position - 1` (prepend)
|
|
83
|
+
- Both present → `(prev.position + next.position) / 2.0` (midpoint)
|
|
84
|
+
|
|
85
|
+
### Gap exhaustion (rebalancing) {#rebalancing}
|
|
86
|
+
|
|
87
|
+
Each midpoint insert into the *same* slot halves the gap (`1.0 → 0.5 → 0.25 → …`), so after roughly 20 consecutive insertions the gap drops below `EPSILON` (`1e-6`). At that point `reposition!` rebalances **only that scope group** — renumbering every row in the group to fresh integers (`1.0, 2.0, 3.0, …`) in current-position order, inside a transaction — then reloads the two neighbors and writes the new midpoint. Other scope groups are untouched. End moves (a `nil` neighbor) never rebalance: they always have integer room via `prev ± 1`.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## `backfill_positions!(order: :created_at)`
|
|
92
|
+
|
|
93
|
+
Numbers all existing rows per scope group as `1.0, 2.0, 3.0, …` sorted by `order`. Safe to run on an empty table. Use this in a migration or seed task to initialize positions on existing data:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# In a migration after adding the column:
|
|
97
|
+
Task.backfill_positions!(order: :created_at)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## `position_on` DSL modes
|
|
103
|
+
|
|
104
|
+
The `position_on` call inside `kanban do…end` controls how Plutonium persists positions after a drag-and-drop. Three modes are available:
|
|
105
|
+
|
|
106
|
+
### Mode A — delegate (default)
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
kanban do
|
|
110
|
+
# Implicit: position_on :position
|
|
111
|
+
# Explicit with custom attribute:
|
|
112
|
+
position_on :sort_order
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning` and call `positioned_on`.
|
|
117
|
+
|
|
118
|
+
### Mode B — BYO block
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
kanban do
|
|
122
|
+
position_on :sort_order do |move|
|
|
123
|
+
# move.record — the dropped record
|
|
124
|
+
# move.column — destination column key (Symbol)
|
|
125
|
+
# move.prev — record immediately before the slot (or nil)
|
|
126
|
+
# move.next — record immediately after the slot (or nil)
|
|
127
|
+
# move.index — 0-based insertion index within the destination column
|
|
128
|
+
move.record.update!(sort_order: my_position(move.prev, move.next))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Kanban::Positioning::Move` argument — it is NOT `instance_exec`'d, so `self` is the proc's original binding.
|
|
134
|
+
|
|
135
|
+
### Mode C — disabled
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
kanban do
|
|
139
|
+
position_on false
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
No ordering is applied (relation is returned unchanged). On drop, `on_exit`/`on_enter` still fire; the position attribute is never touched. Cards render in the relation's default order.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Pure math helpers
|
|
148
|
+
|
|
149
|
+
Available as module-level methods without an AR instance:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
Plutonium::Positioning.position_between(1.0, 3.0) # => 2.0
|
|
153
|
+
Plutonium::Positioning.position_between(nil, 5.0) # => 4.0 (prepend)
|
|
154
|
+
Plutonium::Positioning.position_between(5.0, nil) # => 6.0 (append)
|
|
155
|
+
Plutonium::Positioning.position_between(nil, nil) # => 0.0 (first item)
|
|
156
|
+
|
|
157
|
+
Plutonium::Positioning.gap_exhausted?(1.0, 1.0) # => true
|
|
158
|
+
Plutonium::Positioning.gap_exhausted?(1.0, 3.0) # => false
|
|
159
|
+
Plutonium::Positioning.gap_exhausted?(nil, 5.0) # => false
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`EPSILON = 1e-6` is the minimum gap before `gap_exhausted?` returns `true`.
|
|
@@ -121,7 +121,7 @@ These render automatically — declare an `as:` only to override or pass options
|
|
|
121
121
|
|---|---|---|
|
|
122
122
|
| `boolean` | Yes/No pill (`:boolean`) | green "Yes" / neutral "No"; override with `true_label:` / `false_label:` |
|
|
123
123
|
| `enum` | status badge (`:badge`) | known statuses auto-colored; unknown values get a stable decorative color; override per-value with `colors:` |
|
|
124
|
-
| `has_cents` decimal | currency (`:currency`) | delimited, 2 decimals
|
|
124
|
+
| `has_cents` decimal | currency (`:currency`) | delimited, 2 decimals; symbol from `unit:` on `has_cents` (model-wide) or per-display, else `config.default_currency_unit` / the i18n default (see below) |
|
|
125
125
|
|
|
126
126
|
```ruby
|
|
127
127
|
display :status, as: :badge, colors: {archived: :neutral, vip: :accent}
|
|
@@ -129,6 +129,15 @@ display :price, as: :currency, unit: "£"
|
|
|
129
129
|
display :active, as: :boolean, true_label: "Live", false_label: "Off"
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
**Currency symbol.** The `unit:` can be set on the model's `has_cents` declaration
|
|
133
|
+
(`has_cents :price_cents, unit: "£"`, or `unit: :currency_symbol` to read a method
|
|
134
|
+
off the record for per-row currencies). That model-level unit is used everywhere the
|
|
135
|
+
value renders as currency — the show page, tables, **and grid/kanban cards**. A
|
|
136
|
+
per-display `unit:` overrides it for that one display; `unit: false` explicitly
|
|
137
|
+
renders no symbol. When neither is set, currency falls back to
|
|
138
|
+
`Plutonium.configuration.default_currency_unit` (default: the i18n
|
|
139
|
+
`number.currency.format.unit` if the locale defines it — `$` in `en` — else no symbol).
|
|
140
|
+
|
|
132
141
|
## Field options
|
|
133
142
|
|
|
134
143
|
```ruby
|
|
@@ -681,6 +690,22 @@ end
|
|
|
681
690
|
|
|
682
691
|
`modal:` is the default for framework `:new`/`:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win — see [Actions](./actions).
|
|
683
692
|
|
|
693
|
+
### `show_in` {#show_in}
|
|
694
|
+
|
|
695
|
+
```ruby
|
|
696
|
+
class PostDefinition < ResourceDefinition
|
|
697
|
+
show_in :modal # open the show page in a centered modal from table & grid links
|
|
698
|
+
# show_in :page # (default) full-page navigation to the show route
|
|
699
|
+
end
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
Controls how the **show page** opens when a record is clicked in the table or grid (and serves as the default for a [kanban board](/reference/kanban/dsl#show_in), which can override it per-board):
|
|
703
|
+
|
|
704
|
+
- `:page` (default) — full-page navigation to the show route.
|
|
705
|
+
- `:modal` — the show page opens in a **centered** dialog. This is deliberately independent of `modal:`/`modal_mode` above (which styles `:new`/`:edit`) — show is always centered, never a slideover. From inside the modal an expand icon opens the full page in a new tab; ⌘/Ctrl-click (or middle-click) on the row/card does the same directly.
|
|
706
|
+
|
|
707
|
+
An unknown mode raises `ArgumentError`.
|
|
708
|
+
|
|
684
709
|
## Metadata panel (show page)
|
|
685
710
|
|
|
686
711
|
A right-side aside on the show page rendering label/value rows. Keeps the main card focused on substance; chrome (timestamps, ownership, system flags) lives in the aside.
|
|
@@ -97,6 +97,9 @@ class Product < ResourceRecord
|
|
|
97
97
|
has_cents :cost_cents, name: :wholesale # custom accessor name
|
|
98
98
|
has_cents :tax_cents, rate: 1000 # 3 decimal places (e.g. for fractional currencies)
|
|
99
99
|
has_cents :amount_yen, rate: 1 # currencies with no subunit (JPY)
|
|
100
|
+
has_cents :gbp_cents, unit: "£" # currency symbol used wherever this renders as currency
|
|
101
|
+
has_cents :multi_cents, unit: :currency_symbol # per-row: reads record.currency_symbol
|
|
102
|
+
has_cents :points_cents, unit: false # explicitly no symbol (skips the default)
|
|
100
103
|
end
|
|
101
104
|
|
|
102
105
|
product = Product.new
|
|
@@ -109,6 +112,29 @@ product.price = 10.999
|
|
|
109
112
|
product.price_cents # => 1099
|
|
110
113
|
```
|
|
111
114
|
|
|
115
|
+
**Currency symbol (`unit:`)** — a `String` is used verbatim (`unit: "£"`); a `Symbol`
|
|
116
|
+
names a method read off the record for per-row currencies (`unit: :currency_symbol`
|
|
117
|
+
→ `record.currency_symbol`); `false` explicitly renders no symbol. This unit is picked
|
|
118
|
+
up automatically anywhere the value renders as currency — show pages, tables, and
|
|
119
|
+
grid/kanban cards — so you configure it once on the model. A per-display
|
|
120
|
+
`display :price, as: :currency, unit: …` overrides it for that display.
|
|
121
|
+
|
|
122
|
+
A `has_cents` field also **infers the currency input** on forms: a bare `input :price`
|
|
123
|
+
renders the [currency input](../ui/forms#currency-fields) (number field + unit prefix),
|
|
124
|
+
no `as: :currency` needed — matching the display side.
|
|
125
|
+
|
|
126
|
+
Resolution is `display unit → has_cents unit → config default`, where `nil` means
|
|
127
|
+
"not set, keep looking" and `false` means "stop, no symbol". When nothing is set, it
|
|
128
|
+
falls back to `Plutonium.configuration.default_currency_unit`, which itself defaults
|
|
129
|
+
to the i18n `number.currency.format.unit` *if the locale defines it* (`$` in `en`),
|
|
130
|
+
else no symbol:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Plutonium.configure do |config|
|
|
134
|
+
config.default_currency_unit = "£" # app-wide default; false for no symbol; nil → i18n-if-set
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
112
138
|
::: danger Use the virtual accessor in policies and definitions
|
|
113
139
|
Reference `:price`, NOT `:price_cents`:
|
|
114
140
|
|