plutonium 0.56.0 → 0.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +1 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +1 -1
  4. data/.claude/skills/plutonium-auth/SKILL.md +1 -1
  5. data/.claude/skills/plutonium-resource/SKILL.md +35 -1
  6. data/CHANGELOG.md +13 -0
  7. data/CONTRIBUTING.md +1 -1
  8. data/README.md +38 -16
  9. data/docs/getting-started/installation.md +2 -2
  10. data/docs/getting-started/tutorial/02-first-resource.md +1 -1
  11. data/docs/getting-started/tutorial/03-authentication.md +3 -3
  12. data/docs/guides/adding-resources.md +1 -1
  13. data/docs/guides/authentication.md +1 -1
  14. data/docs/guides/creating-packages.md +1 -1
  15. data/docs/guides/multi-tenancy.md +1 -1
  16. data/docs/guides/nested-resources.md +1 -1
  17. data/docs/guides/user-invites.md +1 -1
  18. data/docs/guides/user-profile.md +1 -1
  19. data/docs/reference/app/generators.md +3 -3
  20. data/docs/reference/app/index.md +1 -1
  21. data/docs/reference/app/portals.md +1 -1
  22. data/docs/reference/auth/profile.md +1 -1
  23. data/docs/reference/resource/actions.md +55 -0
  24. data/docs/reference/resource/index.md +1 -1
  25. data/docs/reference/tenancy/invites.md +1 -1
  26. data/gemfiles/rails_7.gemfile.lock +1 -1
  27. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  28. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  29. data/lib/generators/pu/core/typespec/typespec_generator.rb +1 -1
  30. data/lib/generators/pu/saas/welcome_generator.rb +1 -1
  31. data/lib/plutonium/action/base.rb +19 -2
  32. data/lib/plutonium/action/condition_context.rb +33 -0
  33. data/lib/plutonium/ui/grid/card.rb +3 -2
  34. data/lib/plutonium/ui/page/index.rb +1 -1
  35. data/lib/plutonium/ui/page/show.rb +1 -1
  36. data/lib/plutonium/ui/table/resource.rb +2 -2
  37. data/lib/plutonium/version.rb +1 -1
  38. data/package.json +1 -1
  39. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdb3a70cfc50828cc986040543d72e8e576497deffb3304aac601b113c507370
4
- data.tar.gz: 7901dd81e5a9f11519224695071e1fe76c3121e8d311d1b1d6d7445fb2179667
3
+ metadata.gz: 17f6996a0503aeccb6070cb0c379d09bfd253e2356a839745368bcaf0110e377
4
+ data.tar.gz: 0603760cfc2a10d6bedaa4b225b33fc5cf5b632a36829c4e6c9e9c4ce7b7971b
5
5
  SHA512:
6
- metadata.gz: 3d89dc0f91a27bb0e0e1ac5503cb11059d35309767fa006c200216819e0c123d6d2a558a03ac162964527b992fabc974184d5f357f3b0389405f2c63a13bdfed
7
- data.tar.gz: 584146030273aeac56b898049f4f9e6a05f364c1b334bdca4ef2e044288548f1277c4d419cf7051504d36e0fd32ffbe48a4540c03f7b80f08405f43b4a0313ab
6
+ metadata.gz: b77a88b0c0e04b5a9e143c88b292ec305f7d5c8ef74efe3e0891f20c772e63eef35383a7785338863f4cd9a942c5b76c94c1fa6fedc2995c41da8df680f1e390
7
+ data.tar.gz: 7dd9fd4420c8145227929c4f6ec434520c6701ea387767d9395bb74b413d2e99ce451611c89797604a1cf5a62bb51368a4aa2f5c79d1b698a1c11842979dcb29
@@ -127,7 +127,7 @@ Meta-generators (`pu:saas:setup`) propagate flags to the generators they chain.
127
127
 
128
128
  1. **Load the bootstrap bundle** (or the targeted skill from the router table).
129
129
  2. **Generate** — `rails g pu:res:scaffold Model field:type ... --dest=main_app`.
130
- 3. **Migrate** — `rails db:migrate`.
130
+ 3. **Migrate** — `rails db:prepare`.
131
131
  4. **Connect** — `rails g pu:res:conn Model --dest=portal_name`.
132
132
  5. **Customize** — edit definition / policy as needed.
133
133
  6. **Verify** — hit the route in the browser.
@@ -62,7 +62,7 @@ rails generate pu:pkg:portal admin --auth=user
62
62
 
63
63
  # 4. First resource
64
64
  rails generate pu:res:scaffold Post user:belongs_to title:string 'content:text?' --dest=main_app
65
- rails db:migrate
65
+ rails db:prepare
66
66
 
67
67
  # 5. Connect resource to portal
68
68
  rails generate pu:res:conn Post --dest=admin_portal
@@ -279,7 +279,7 @@ rails g pu:profile:setup date_of_birth:date bio:text \
279
279
  rails generate pu:profile:install bio:text avatar:attachment 'timezone:string?' \
280
280
  --dest=customer
281
281
 
282
- rails db:migrate
282
+ rails db:prepare
283
283
 
284
284
  rails generate pu:profile:conn --dest=customer_portal
285
285
  ```
@@ -29,7 +29,7 @@ For tenancy / `associated_with` / `relation_scope`, load [[plutonium-tenancy]].
29
29
  1. Pick destination: `--dest=main_app` or `--dest=package_name`.
30
30
  2. Run `rails g pu:res:scaffold ResourceName field:type ... --dest=<dest>`.
31
31
  3. Review the generated migration — add cascade deletes, composite indexes, defaults.
32
- 4. `rails db:migrate`.
32
+ 4. `rails db:prepare`.
33
33
  5. `rails g pu:res:conn ResourceName --dest=<portal_name>`.
34
34
  6. Customize the policy's `permitted_attributes_for_*` as needed.
35
35
  7. Open the portal route in the browser.
@@ -1050,6 +1050,10 @@ action :name,
1050
1050
  collection_record_action: true,
1051
1051
  bulk_action: true,
1052
1052
 
1053
+ # Conditional visibility — display-only toggle, NOT authorization (see below).
1054
+ # `-> { false }` keeps the route live but hides the button (e.g. API-only).
1055
+ condition: -> { params[:beta] == "1" },
1056
+
1053
1057
  # Grouping
1054
1058
  category: :primary, # :primary, :secondary, :danger
1055
1059
  position: 50,
@@ -1062,6 +1066,36 @@ action :name,
1062
1066
  size: :lg # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
1063
1067
  ```
1064
1068
 
1069
+ ### Conditional Actions (`condition:`)
1070
+
1071
+ Like `condition:` on inputs/displays/columns — define an action but render its **button** only when a runtime proc is truthy. The action and its route stay live either way; `condition:` only toggles the UI.
1072
+
1073
+ Headline use case: **expose an action's endpoint without a button** — one you call from the API, a webhook, or another service. Hide it with an always-falsy condition; the route still works:
1074
+
1075
+ ```ruby
1076
+ # Defined and callable (API / programmatic), but no button anywhere:
1077
+ action :sync_inventory, interaction: SyncInventoryInteraction, condition: -> { false }
1078
+
1079
+ # Per-record display state — object is the row/shown record:
1080
+ action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }
1081
+
1082
+ # View/request-level toggle (feature flag, beta mode):
1083
+ action :preview, interaction: PreviewInteraction, condition: -> { params[:beta] == "1" }
1084
+ ```
1085
+
1086
+ Inside the proc, `object`/`record` is the contextual record — the row/shown record for **record** and **collection-record** actions, **nil** for **resource** and **bulk** actions (guard with `object&.…` if shared). Every other call delegates to the **view context**: `current_user`, `current_parent`, `params`, `request`, `allowed_to?`, `resource_record!`, etc. `object` is evaluated per row in tables/grids, so per-record show/hide works there.
1087
+
1088
+ 🚨 **`condition:` is NOT authorization — it only hides the button.** A hidden action still has a live route; anyone with the URL can trigger it. "Who may run this" belongs in the policy:
1089
+
1090
+ ```ruby
1091
+ # 🚫 WRONG — does not stop non-admins; the route is live.
1092
+ action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
1093
+ # ✅ RIGHT — authorization in the policy, enforced regardless of condition:
1094
+ def wipe? = current_user.admin?
1095
+ ```
1096
+
1097
+ The two compose: an action's button shows only when the policy permits **and** the condition is truthy; execution is gated by the policy alone. Use `object` in `condition:` for per-record *display*; use the policy for per-record *authorization*.
1098
+
1065
1099
  `Action#with(...)` — actions are frozen value objects; clone with overrides:
1066
1100
 
1067
1101
  ```ruby
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [0.56.1] - 2026-06-05
2
+
3
+ ### 🚀 Features
4
+
5
+ - *(actions)* Add display-only condition: to actions
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - *(dummy)* Version-adapt kitchen_sinks migration
10
+
11
+ ### 📚 Documentation
12
+
13
+ - Recommend db:prepare, firm up README, document conditional actions
1
14
  ## [0.56.0] - 2026-06-05
2
15
 
3
16
  ### 🚀 Features
data/CONTRIBUTING.md CHANGED
@@ -153,7 +153,7 @@ Use the dummy app:
153
153
  ```bash
154
154
  cd test/dummy
155
155
  rails g pu:res:scaffold TestModel name:string --dest=main_app
156
- rails db:migrate
156
+ rails db:prepare
157
157
  bin/dev
158
158
  ```
159
159
 
data/README.md CHANGED
@@ -2,8 +2,11 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/plutonium.svg)](https://badge.fury.io/rb/plutonium)
4
4
  [![Ruby](https://github.com/radioactive-labs/plutonium-core/actions/workflows/main.yml/badge.svg)](https://github.com/radioactive-labs/plutonium-core/actions/workflows/main.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.txt)
5
6
 
6
- Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, and AI-ready. Plutonium picks up where Rails left off, adding application-level concepts that make building complex apps faster.
7
+ **The Rails framework for things you should never write again.**
8
+
9
+ Convention over configuration, extended to everything you keep rebuilding: **CRUD. Auth. Authorization. Multi-tenancy. Admin portals. Search, filters, bulk actions.** All generated. All customizable. All Rails.
7
10
 
8
11
  ## Quick Start
9
12
 
@@ -12,20 +15,36 @@ rails new myapp -a propshaft -j esbuild -c tailwind \
12
15
  -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
13
16
  ```
14
17
 
15
- Then create your first resource:
18
+ Then scaffold a resource, create a portal, and connect them:
16
19
 
17
20
  ```bash
18
21
  cd myapp
19
- rails g pu:res:scaffold Post title:string body:text --dest=main_app
20
- rails db:migrate
22
+
23
+ # Scaffold a resource — model, migration, definition, policy
24
+ rails g pu:res:scaffold Post title:string body:text published_at:datetime --dest=main_app
25
+ rails db:prepare
26
+
27
+ # Create a portal (web interface) and connect the resource to it
28
+ rails g pu:pkg:portal app --public
29
+ rails g pu:res:conn Post --dest=app_portal
30
+
21
31
  bin/dev
22
32
  ```
23
33
 
24
- Visit `http://localhost:3000` - you have a complete CRUD interface.
34
+ Visit `http://localhost:3000/app/posts` you have a complete CRUD interface.
25
35
 
26
- ## What You Get
36
+ ## What You Stop Writing
27
37
 
28
- **Resource-oriented architecture** - Models, policies, definitions, and controllers that work together:
38
+ Same scaffold command you already know. A very different surface area.
39
+
40
+ ```bash
41
+ rails g scaffold Post ... # Rails: just CRUD
42
+ rails g pu:res:scaffold Post ... # Plutonium: full CRUD + search + filters + bulk actions
43
+ ```
44
+
45
+ And it doesn't stop at scaffolds:
46
+
47
+ **Resource-oriented architecture** — models, policies, definitions, and controllers that work together:
29
48
 
30
49
  ```ruby
31
50
  # Policy controls WHO can do WHAT
@@ -47,7 +66,7 @@ class PostDefinition < ResourceDefinition
47
66
  end
48
67
  ```
49
68
 
50
- **Packages for organization** - Split your app into feature packages and portals:
69
+ **Packages and portals** split your app into feature engines and themed web interfaces:
51
70
 
52
71
  ```bash
53
72
  rails g pu:pkg:package blogging # Business logic
@@ -65,7 +84,7 @@ rails g pu:rodauth:account user
65
84
  **Multi-tenancy** with entity scoping:
66
85
 
67
86
  ```ruby
68
- # In portal engine
87
+ # In a portal engine
69
88
  scope_to_entity Organization, strategy: :path
70
89
  # Routes become /organizations/:organization_id/posts
71
90
  ```
@@ -86,6 +105,13 @@ class PublishInteraction < ResourceInteraction
86
105
  end
87
106
  ```
88
107
 
108
+ ## Why Plutonium
109
+
110
+ - **Convention over configuration** — extended to resources, policies, portals, and tenancy, not just routes and views.
111
+ - **It's just Rails** — generated code lives in your repo. Edit it, override it, delete it. The "magic" is regular Ruby mixins you can read.
112
+ - **Multi-tenant ready** — path or domain tenancy, scoped relations, invites and memberships out of the box.
113
+ - **AI-readable** — predictable file layout and naming, plus built-in [Claude Code skills](.claude/skills) that teach AI assistants the patterns.
114
+
89
115
  ## Documentation
90
116
 
91
117
  Full documentation at **[radioactive-labs.github.io/plutonium-core](https://radioactive-labs.github.io/plutonium-core/)**
@@ -97,18 +123,14 @@ Full documentation at **[radioactive-labs.github.io/plutonium-core](https://radi
97
123
 
98
124
  ## Requirements
99
125
 
100
- - Ruby 3.2+
101
- - Rails 7.1+ (Rails 8 recommended)
126
+ - Ruby 3.2.2+
127
+ - Rails 7.2+ (Rails 8 recommended)
102
128
  - Node.js 18+
103
129
 
104
130
  ## Contributing
105
131
 
106
132
  See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
107
133
 
108
- ## Status
109
-
110
- Plutonium is used in production but still evolving. APIs may change between minor versions. Pin your version in Gemfile.
111
-
112
134
  ## License
113
135
 
114
- MIT License - see [LICENSE](LICENSE).
136
+ MIT License see [LICENSE.txt](LICENSE.txt).
@@ -15,7 +15,7 @@ After the template completes:
15
15
 
16
16
  ```bash
17
17
  cd myapp
18
- rails db:migrate
18
+ rails db:prepare
19
19
  bin/dev
20
20
  ```
21
21
 
@@ -51,7 +51,7 @@ rails generate pu:core:install
51
51
  ```bash
52
52
  rails generate pu:rodauth:install
53
53
  rails generate pu:rodauth:account user
54
- rails db:migrate
54
+ rails db:prepare
55
55
  ```
56
56
 
57
57
  For account options and customization, see [Reference › Auth](/reference/auth/) and [Guides › Authentication](/guides/authentication).
@@ -113,7 +113,7 @@ end
113
113
  ## Running the Migration
114
114
 
115
115
  ```bash
116
- rails db:migrate
116
+ rails db:prepare
117
117
  ```
118
118
 
119
119
  ## Creating a Portal
@@ -28,7 +28,7 @@ Plutonium supports multiple account types. For admins, use the dedicated `pu:rod
28
28
 
29
29
  ```bash
30
30
  rails generate pu:rodauth:admin admin
31
- rails db:migrate
31
+ rails db:prepare
32
32
  ```
33
33
 
34
34
  For self-service user accounts, the corresponding command is `rails generate pu:rodauth:account user`.
@@ -151,7 +151,7 @@ Our blog needs users (authors) who can create posts. Let's create a User account
151
151
 
152
152
  ```bash
153
153
  rails generate pu:rodauth:account user
154
- rails db:migrate
154
+ rails db:prepare
155
155
  ```
156
156
 
157
157
  This creates a `User` model similar to `Admin`, but with public registration enabled.
@@ -177,7 +177,7 @@ end
177
177
  Run the migration:
178
178
 
179
179
  ```bash
180
- rails db:migrate
180
+ rails db:prepare
181
181
  ```
182
182
 
183
183
  Update the Post model to include the association:
@@ -29,7 +29,7 @@ Plutonium generates a basic migration. Before running it, edit `db/migrate/<time
29
29
  ### 3. Run the migration
30
30
 
31
31
  ```bash
32
- rails db:migrate
32
+ rails db:prepare
33
33
  ```
34
34
 
35
35
  ### 4. Connect to a portal
@@ -16,7 +16,7 @@ rails generate pu:rodauth:install
16
16
  rails generate pu:rodauth:account user
17
17
 
18
18
  # 3. Run migrations
19
- rails db:migrate
19
+ rails db:prepare
20
20
 
21
21
  # 4. Wire auth into a portal
22
22
  # (when you run `pu:pkg:portal admin --auth=user`, this happens automatically)
@@ -40,7 +40,7 @@ packages/blogging/
40
40
 
41
41
  ```bash
42
42
  rails g pu:res:scaffold Blogging::Post title:string --dest=blogging
43
- rails db:migrate
43
+ rails db:prepare
44
44
  ```
45
45
 
46
46
  ### 4. Expose it via a portal
@@ -42,7 +42,7 @@ rails g pu:res:scaffold Organization name:string:uniq slug:string:uniq --dest=ma
42
42
 
43
43
  ```bash
44
44
  rails g pu:res:scaffold Post organization:belongs_to title:string content:text --dest=main_app
45
- rails db:migrate
45
+ rails db:prepare
46
46
  ```
47
47
 
48
48
  ### 3. Scope the portal to the entity
@@ -20,7 +20,7 @@ All of this happens with no manual route wiring — Plutonium generates it from
20
20
  ```bash
21
21
  rails g pu:res:scaffold Company name:string --dest=main_app
22
22
  rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
23
- rails db:migrate
23
+ rails db:prepare
24
24
  ```
25
25
 
26
26
  ### 2. Connect both to the portal
@@ -49,7 +49,7 @@ rails g pu:invites:install \
49
49
  ### 2. Migrate
50
50
 
51
51
  ```bash
52
- rails db:migrate
52
+ rails db:prepare
53
53
  ```
54
54
 
55
55
  ### 3. Connect to your portal
@@ -55,7 +55,7 @@ By default the model is `{UserModel}Profile` (`UserProfile`, `StaffUserProfile`,
55
55
  ### 2. Migrate
56
56
 
57
57
  ```bash
58
- rails db:migrate
58
+ rails db:prepare
59
59
  ```
60
60
 
61
61
  ### 3. Connect to a portal
@@ -455,7 +455,7 @@ rails generate pu:pkg:portal admin --auth=admin
455
455
  rails generate pu:res:conn Post Comment --dest=admin_portal
456
456
 
457
457
  # 6. Migrate
458
- rails db:migrate
458
+ rails db:prepare
459
459
 
460
460
  # 7. Create the first admin
461
461
  rails rodauth_admin:create[admin@example.com,password123]
@@ -465,7 +465,7 @@ rails rodauth_admin:create[admin@example.com,password123]
465
465
 
466
466
  ```bash
467
467
  rails g pu:res:scaffold Product name:string price_cents:integer --dest=main_app
468
- rails db:migrate
468
+ rails db:prepare
469
469
  rails g pu:res:conn Product --dest=admin_portal
470
470
  ```
471
471
 
@@ -474,7 +474,7 @@ rails g pu:res:conn Product --dest=admin_portal
474
474
  ```bash
475
475
  rails g pu:pkg:portal customer --auth=user --scope=Organization
476
476
  rails g pu:res:conn Order --dest=customer_portal
477
- rails db:migrate
477
+ rails db:prepare
478
478
  ```
479
479
 
480
480
  ## Undoing generators
@@ -50,7 +50,7 @@ rails generate pu:pkg:portal admin --auth=user
50
50
 
51
51
  # 4. First resource
52
52
  rails generate pu:res:scaffold Post user:belongs_to title:string 'content:text?' --dest=main_app
53
- rails db:migrate
53
+ rails db:prepare
54
54
 
55
55
  # 5. Connect resource to portal
56
56
  rails generate pu:res:conn Post --dest=admin_portal
@@ -195,7 +195,7 @@ rails g pu:res:conn Profile --dest=customer_portal --singular
195
195
  ```
196
196
 
197
197
  ::: tip Run after migrations
198
- The generator reads model columns to seed the policy's `permitted_attributes_for_*`. Run `rails db:migrate` first.
198
+ The generator reads model columns to seed the policy's `permitted_attributes_for_*`. Run `rails db:prepare` first.
199
199
  :::
200
200
 
201
201
  ### What gets generated
@@ -24,7 +24,7 @@ Meta-generator: runs `pu:profile:install` + `pu:profile:conn` in one shot.
24
24
  rails generate pu:profile:install bio:text avatar:attachment 'timezone:string?' \
25
25
  --dest=customer
26
26
 
27
- rails db:migrate
27
+ rails db:prepare
28
28
 
29
29
  rails generate pu:profile:conn --dest=customer_portal
30
30
  ```
@@ -61,6 +61,9 @@ action :name,
61
61
  collection_record_action: true,
62
62
  bulk_action: true,
63
63
 
64
+ # Conditional visibility — display-only proc, NOT authorization (see below)
65
+ condition: -> { params[:beta] == "1" },
66
+
64
67
  # Grouping
65
68
  category: :primary, # :primary, :secondary, :danger
66
69
  position: 50, # display order (lower = first)
@@ -84,6 +87,58 @@ def customize_actions
84
87
  end
85
88
  ```
86
89
 
90
+ ## Conditional visibility — `condition:`
91
+
92
+ Like the `condition:` proc on [inputs/displays/columns](/reference/resource/definition), an action can be **defined but only rendered when a runtime proc is truthy**. It's purely a toggle on whether the **button is shown** — the action (and its route) stays fully live either way.
93
+
94
+ The headline use case: **expose an action's endpoint without surfacing it in the UI** — e.g. one you call from the API, a webhook, or another service. Hide the button with an always-falsy condition; the route still works:
95
+
96
+ ```ruby
97
+ # Defined and callable (API / programmatic), but no button anywhere in the UI:
98
+ action :sync_inventory, interaction: SyncInventoryInteraction, condition: -> { false }
99
+ ```
100
+
101
+ It also works as a dynamic toggle driven by the **record** or the **view/request** context:
102
+
103
+ ```ruby
104
+ # object → the row/shown record (record & collection-record actions):
105
+ action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }
106
+ # view/request state — feature flag, preview/beta mode:
107
+ action :preview, interaction: PreviewInteraction, condition: -> { params[:beta] == "1" }
108
+ ```
109
+
110
+ Inside the proc, `object`/`record` is the contextual record, and every other call delegates to the **view context**:
111
+
112
+ | Available | Notes |
113
+ |---|---|
114
+ | `object` / `record` | The row/shown record for **record** and **collection-record** actions; **`nil`** for resource and bulk actions (no single record). Guard with `object&.…` if a condition is shared across action kinds. |
115
+ | `params`, `request` | Current request. |
116
+ | `current_user`, `current_parent` | The signed-in user and (nested) parent. |
117
+ | `resource_record!` | The shown record on the show page; raises on index/table — prefer `object`. |
118
+ | `allowed_to?`, `policy_for`, other helpers | The usual view helpers. |
119
+
120
+ `object` is evaluated **per row** in tables and grids, so per-record show/hide works there too.
121
+
122
+ ::: danger `condition:` is NOT authorization — it only hides the button
123
+ A hidden action still has a **live route**: anyone who knows the URL can still trigger it. `condition:` decides whether the *button renders*, never whether the *request is allowed*.
124
+
125
+ ```ruby
126
+ # 🚫 WRONG — this does NOT stop non-admins. The route is live; they can POST to it.
127
+ action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
128
+
129
+ # ✅ RIGHT — authorization belongs in the policy. The action only runs if this returns true.
130
+ class WidgetPolicy < ResourcePolicy
131
+ def wipe? = current_user.admin?
132
+ end
133
+ ```
134
+
135
+ **Rule of thumb:** "who may run this" → **policy** (`def action_name?`). "is this UI relevant right now" → `condition:`. Authorization is enforced regardless of `condition:`; the two compose — an action appears only when the policy permits **and** the condition is truthy.
136
+ :::
137
+
138
+ ::: tip Per-record display vs. per-record authorization
139
+ `condition: -> { object.draft? }` is fine for **showing/hiding** a per-record button. But if the rule is about **who may run it** ("only while draft *and* nobody else has it locked"), put it in the policy — `def publish? = record.draft?` is also evaluated per record (per row), and unlike `condition:` it actually gates execution.
140
+ :::
141
+
87
142
  ## Simple actions (navigation)
88
143
 
89
144
  Link to an existing route. The target route MUST exist.
@@ -14,7 +14,7 @@ A **resource** is the unit Plutonium gives you full CRUD for — list, show, cre
14
14
 
15
15
  ```bash
16
16
  rails g pu:res:scaffold Post user:belongs_to title:string 'content:text?' --dest=main_app
17
- rails db:migrate
17
+ rails db:prepare
18
18
  rails g pu:res:conn Post --dest=admin_portal
19
19
  ```
20
20
 
@@ -56,7 +56,7 @@ rails g pu:invites:install \
56
56
  After install:
57
57
 
58
58
  ```bash
59
- rails db:migrate
59
+ rails db:prepare
60
60
  ```
61
61
 
62
62
  ## What gets created
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.55.0)
4
+ plutonium (0.56.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.55.0)
4
+ plutonium (0.56.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.55.0)
4
+ plutonium (0.56.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -42,7 +42,7 @@ module Pu
42
42
  say_status :pending, "#{migration.version} - #{migration.name}", :yellow
43
43
  end
44
44
  say ""
45
- say "Run `bin/rails db:migrate` before generating TypeSpec specifications.", :red
45
+ say "Run `bin/rails db:prepare` before generating TypeSpec specifications.", :red
46
46
  raise Thor::Error, "Cannot generate TypeSpec with pending migrations"
47
47
  end
48
48
 
@@ -135,7 +135,7 @@ module Pu
135
135
  say "Next steps:"
136
136
  say "\n"
137
137
  say "1. Run migrations (if you haven't already):"
138
- say " rails db:migrate"
138
+ say " rails db:prepare"
139
139
  say "\n"
140
140
  say "2. Customize the onboarding view to match your app:"
141
141
  say " app/views/welcome/onboarding.html.erb"
@@ -6,7 +6,7 @@ module Plutonium
6
6
  module Action
7
7
  # Base class for all actions in the Plutonium framework.
8
8
  class Base
9
- attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :color, :category, :position, :return_to
9
+ attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :color, :category, :position, :return_to, :condition
10
10
 
11
11
  def initialize(name, **options)
12
12
  @name = name.to_sym
@@ -27,6 +27,7 @@ module Plutonium
27
27
  @position = options[:position] || 50
28
28
  @modal_mode = options[:modal]
29
29
  @modal_size = options[:size]
30
+ @condition = options[:condition]
30
31
  validate_modal_mode!
31
32
  validate_modal_size!
32
33
 
@@ -62,6 +63,21 @@ module Plutonium
62
63
  policy.allowed_to?(:"#{name}?")
63
64
  end
64
65
 
66
+ # Display-only visibility gate, mirroring the `condition:` proc on
67
+ # inputs/displays/columns. Returns true when no condition is set.
68
+ #
69
+ # The proc is evaluated against a ConditionContext: `object`/`record` is
70
+ # the contextual record (nil for resource/bulk actions), and every other
71
+ # call delegates to the view context (current_user, params, request,
72
+ # allowed_to?, resource_record!, …).
73
+ #
74
+ # NOT an authorization boundary — a hidden action still has a live route;
75
+ # keep authorization in the policy.
76
+ def condition_met?(view_context, record: nil)
77
+ return true if @condition.nil?
78
+ ConditionContext.new(view_context, record).instance_exec(&@condition)
79
+ end
80
+
65
81
  # Returns a new Action with the given options merged over this one.
66
82
  def with(**overrides)
67
83
  self.class.new(name, **to_options.merge(overrides))
@@ -90,7 +106,8 @@ module Plutonium
90
106
  category: @category.to_sym,
91
107
  position: @position,
92
108
  modal: @modal_mode,
93
- size: @modal_size
109
+ size: @modal_size,
110
+ condition: @condition
94
111
  }
95
112
  end
96
113
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Plutonium
6
+ module Action
7
+ # Evaluation scope for an action's `condition:` proc.
8
+ #
9
+ # Exposes the contextual record as both `object` and `record`, and delegates
10
+ # everything else to the request's **view context** — the object render
11
+ # components forward their helpers to — so a condition can use current_user,
12
+ # params, request, allowed_to?, resource_record!, etc. directly, exactly
13
+ # like the `condition:` procs on inputs/displays/columns.
14
+ #
15
+ # Delegating to the view context (not a render component) matters: the
16
+ # component exposes params/request only as PRIVATE methods, which a delegator
17
+ # can't forward; the view context exposes them publicly.
18
+ #
19
+ # `record`/`object` is the row/shown record for record and
20
+ # collection-record actions, and nil for resource/bulk actions (no single
21
+ # record in scope) — so guard with `object&.…` in conditions shared across
22
+ # action kinds.
23
+ class ConditionContext < SimpleDelegator
24
+ attr_reader :record
25
+ alias_method :object, :record
26
+
27
+ def initialize(view_context, record)
28
+ super(view_context)
29
+ @record = record
30
+ end
31
+ end
32
+ end
33
+ end
@@ -232,12 +232,13 @@ module Plutonium
232
232
 
233
233
  def row_actions
234
234
  @row_actions ||= resource_definition.defined_actions.values.select { |a|
235
- a.collection_record_action? && a.permitted_by?(record_policy)
235
+ a.collection_record_action? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
236
236
  }
237
237
  end
238
238
 
239
239
  def can_show?
240
- resource_definition.defined_actions[:show]&.permitted_by?(record_policy)
240
+ action = resource_definition.defined_actions[:show]
241
+ action&.permitted_by?(record_policy) && action.condition_met?(view_context, record:)
241
242
  end
242
243
 
243
244
  def record_policy
@@ -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) }
36
+ super || current_definition.defined_actions.values.select { |a| a.resource_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
37
37
  end
38
38
 
39
39
  def render_default_content
@@ -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) }
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!) }
19
19
  end
20
20
 
21
21
  def render_default_content
@@ -134,7 +134,7 @@ module Plutonium
134
134
  policy = policy_for(record:)
135
135
 
136
136
  actions = resource_definition.defined_actions
137
- .select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") }
137
+ .select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
138
138
  .values
139
139
 
140
140
  primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
@@ -161,7 +161,7 @@ module Plutonium
161
161
 
162
162
  def bulk_actions
163
163
  @bulk_actions ||= resource_definition.defined_actions
164
- .select { |k, a| a.bulk_action? }
164
+ .select { |k, a| a.bulk_action? && a.condition_met?(view_context) }
165
165
  .values
166
166
  end
167
167
 
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.56.0"
2
+ VERSION = "0.56.1"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.56.0",
3
+ "version": "0.56.1",
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",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.56.0
4
+ version: 0.56.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
@@ -956,6 +956,7 @@ files:
956
956
  - lib/plutonium.rb
957
957
  - lib/plutonium/action/README.md
958
958
  - lib/plutonium/action/base.rb
959
+ - lib/plutonium/action/condition_context.rb
959
960
  - lib/plutonium/action/interactive.rb
960
961
  - lib/plutonium/action/route_options.rb
961
962
  - lib/plutonium/action/simple.rb