plutonium 0.65.0 → 0.66.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 +43 -43
- data/.claude/skills/plutonium-app/SKILL.md +101 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +144 -133
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +151 -96
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +21 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/blog/introducing-plutonium-dashboards.md +4 -5
- data/docs/blog/introducing-plutonium-i18n.md +4 -5
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +1 -1
- data/docs/guides/index.md +3 -3
- data/docs/guides/kanban.md +55 -55
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/reference/app/generators.md +22 -22
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +75 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +6 -6
- data/docs/reference/dashboard/dsl.md +2 -2
- data/docs/reference/dashboard/index.md +1 -1
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +23 -0
- data/docs/reference/index.md +1 -1
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +32 -32
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +17 -15
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +73 -73
- data/docs/reference/resource/export.md +6 -6
- data/docs/reference/resource/index.md +16 -16
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/resource/positioning.md +78 -76
- data/docs/reference/resource/query.md +13 -13
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +57 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +14 -14
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +44 -44
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -10
- metadata +2 -2
|
@@ -20,7 +20,10 @@ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
|
20
20
|
if (!existsSync(source)) continue // skip dirs that aren't skills (no SKILL.md)
|
|
21
21
|
let content = readFileSync(source, "utf8")
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Descriptions are single-quoted YAML scalars (they contain ": "), so strip
|
|
24
|
+
// the quotes and unescape doubled single quotes.
|
|
25
|
+
const rawDescription = content.match(/^description:\s*(.+)$/m)?.[1] ?? ""
|
|
26
|
+
const description = rawDescription.match(/^'(.*)'$/)?.[1].replaceAll("''", "'") ?? rawDescription
|
|
24
27
|
|
|
25
28
|
// Wiki-style [[skill-name]] cross-links become relative markdown links so
|
|
26
29
|
// crawlers can follow them between the published files.
|
|
@@ -34,11 +37,11 @@ skills.sort((a, b) => (a.name === "plutonium" ? -1 : b.name === "plutonium" ? 1
|
|
|
34
37
|
|
|
35
38
|
const index = `# Plutonium Skills
|
|
36
39
|
|
|
37
|
-
Task-focused guides for AI agents working with the [Plutonium](https://radioactive-labs.github.io/plutonium-core/) Rails RAD framework. Each file is self-contained markdown. Start with \`plutonium.md
|
|
40
|
+
Task-focused guides for AI agents working with the [Plutonium](https://radioactive-labs.github.io/plutonium-core/) Rails RAD framework. Each file is self-contained markdown. Start with \`plutonium.md\`; it routes to the others.
|
|
38
41
|
|
|
39
42
|
These are the same skills the gem installs into projects via \`rails g pu:skills:sync\` (Claude Code loads them automatically from \`.claude/skills/\`). Any agent can fetch them directly from the URLs below.
|
|
40
43
|
|
|
41
|
-
${skills.map((s) => `- [${s.name}](${s.name}.md)
|
|
44
|
+
${skills.map((s) => `- [${s.name}](${s.name}.md): ${s.description}`).join("\n")}
|
|
42
45
|
`
|
|
43
46
|
|
|
44
47
|
writeFileSync(join(outDir, "index.md"), index)
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Introducing Plutonium dashboards: metric cards and charts for your Rails admin"
|
|
3
3
|
titleTemplate: "Plutonium Blog"
|
|
4
|
-
date: 2026-10-
|
|
4
|
+
date: 2026-10-02
|
|
5
5
|
description: Plutonium now ships dashboards. Declare metric, chart and free-form cards in one Ruby class, mount it with one routes line, and every card loads in its own turbo frame inside your portal's auth and tenancy.
|
|
6
6
|
author: Stefan Froelich
|
|
7
7
|
tags: [announcement, dashboards, charts]
|
|
8
|
-
draft: true
|
|
9
8
|
---
|
|
10
9
|
|
|
11
10
|
# Introducing Plutonium dashboards: metric cards and charts for your Rails admin
|
|
12
11
|
|
|
13
12
|
<BlogMeta />
|
|
14
13
|
|
|
15
|
-
Every portal Plutonium generates opens on a Dashboard page that lists your resources with a record count each. Anything past that, the numbers and charts an admin actually opens the app to see, was yours to build: a controller action, some instance variables, a view.
|
|
14
|
+
Every portal Plutonium generates opens on a Dashboard page that lists your resources with a record count each. Anything past that, the numbers and charts an admin actually opens the app to see, was yours to build: a controller action, some instance variables, a view. Plutonium 0.65 ships it. A dashboard is a Ruby class of cards, mounted with one line in your routes.
|
|
16
15
|
|
|
17
16
|

|
|
18
17
|
|
|
@@ -20,7 +19,7 @@ Every portal Plutonium generates opens on a Dashboard page that lists your resou
|
|
|
20
19
|
|
|
21
20
|
- A class-level DSL with three kinds of card: `metric` for headline numbers, `chart` for charts, `card` for anything else.
|
|
22
21
|
- `register_dashboard`, which draws the routes and a controller that inherits your portal's authentication, tenant scoping and layout.
|
|
23
|
-
- Lazy loading: every card is fetched in its own turbo frame, behind a skeleton.
|
|
22
|
+
- Lazy loading: by default every card is fetched in its own turbo frame, behind a skeleton.
|
|
24
23
|
- A 12-column grid with a default width per kind of card, plus per-card refresh intervals, conditions, links and icons.
|
|
25
24
|
- A `pu:dashboard` generator, and a Dashboards group in the portal sidebar.
|
|
26
25
|
|
|
@@ -102,7 +101,7 @@ The browser fetches each frame as it scrolls into view, and the card's block run
|
|
|
102
101
|
|
|
103
102
|

|
|
104
103
|
|
|
105
|
-
The Conversion card in that screenshot is declared `lazy: false`, which makes it inline: its block runs in the page request and the finished card is part of the page response, with no frame, no skeleton and no second request. The price is that the page waits for it. Use it for a number cheap enough that a round trip costs more than the query, and leave everything else lazy. An inline card never refreshes, since there is no frame to reload.
|
|
104
|
+
The Conversion card in that screenshot is declared `lazy: false`, which makes it inline: its block runs in the page request and the finished card is part of the page response, with no frame, no skeleton and no second request. The price is that the page waits for it. Use it for a number cheap enough that a round trip costs more than the query, and leave everything else lazy. An inline card never refreshes, since there is no frame to reload, and in development and test an exception in its block fails the whole page rather than one frame.
|
|
106
105
|
|
|
107
106
|
`refresh 60` on the dashboard reloads every lazy card once a minute. `refresh: 10` on a card overrides it, and `refresh: false` keeps an expensive card out of it. Reloads pause while the tab is hidden and catch up when it becomes visible again, so a dashboard left open overnight is not running your aggregates all night.
|
|
108
107
|
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Introducing Plutonium i18n: every string translatable, the Rails way"
|
|
3
3
|
titleTemplate: "Plutonium Blog"
|
|
4
|
-
date: 2026-
|
|
4
|
+
date: 2026-10-02
|
|
5
5
|
description: The text a framework renders has always been the framework's, not yours. Plutonium now routes every string it draws through a locale file, so translating the UI is a matter of YAML and nothing else.
|
|
6
6
|
author: Stefan Froelich
|
|
7
7
|
tags: [announcement, i18n, rails]
|
|
8
|
-
draft: true
|
|
9
8
|
---
|
|
10
9
|
|
|
11
10
|
# Introducing Plutonium i18n: every string translatable, the Rails way
|
|
@@ -14,7 +13,7 @@ draft: true
|
|
|
14
13
|
|
|
15
14
|
You have always been able to translate your own app. Rails has shipped `I18n.t`, per-model attribute names and localized dates since long before you needed them. What you could not translate was the framework on top. The button that says "Create", the "Search..." in the filter box, the flash that a record was saved, the empty-state line, the sentence under the paginator: an admin framework ships those in English, baked into views you adopted the framework precisely so you would never open. Translating them meant forking them.
|
|
16
15
|
|
|
17
|
-
Plutonium now routes every string it renders through a locale file, and resolves every label it derives from a key through the Rails i18n conventions you already know. Translating the UI is a matter of YAML. Nothing in a definition
|
|
16
|
+
Plutonium now routes every string it renders through a locale file, and resolves every label it derives from a key through the Rails i18n conventions you already know. Translating the UI is a matter of YAML. Nothing in a definition or a policy changes.
|
|
18
17
|
|
|
19
18
|
## The strings come from YAML
|
|
20
19
|
|
|
@@ -29,7 +28,7 @@ en:
|
|
|
29
28
|
"false": "Off"
|
|
30
29
|
```
|
|
31
30
|
|
|
32
|
-
That one move covers
|
|
31
|
+
That one move covers rewording the default "Yes" and "No" in English and translating them to another language. There is no second mechanism to learn for the second case.
|
|
33
32
|
|
|
34
33
|
## The labels you never wrote translate too
|
|
35
34
|
|
|
@@ -82,7 +81,7 @@ The Stimulus controllers bundled with the gem read their strings from a JSON blo
|
|
|
82
81
|
|
|
83
82
|
## Ship a half-translated locale
|
|
84
83
|
|
|
85
|
-
You do not have to finish a language before you use it. The dummy demo translates the blog-posts screen and leaves the rest of the app, the dates, and Rodauth's own flashes to fall back to English through `config.i18n.fallbacks`. That matters more than it sounds: the test environment turns on `raise_on_missing_translations`, so a page under a half-done locale would otherwise blow up on the first key you had not reached yet. With fallbacks on, a found-via-fallback string is not missing. Translate the screens your users live on first, ship it, and fill in the rest as you go.
|
|
84
|
+
You do not have to finish a language before you use it. The dummy demo translates the blog-posts screen and leaves the rest of the app, the dates, and Rodauth's own flashes to fall back to English through `config.i18n.fallbacks`. That matters more than it sounds: the dummy's test environment turns on `raise_on_missing_translations`, as many apps do, so a page under a half-done locale would otherwise blow up on the first key you had not reached yet. With fallbacks on, a found-via-fallback string is not missing. Translate the screens your users live on first, ship it, and fill in the rest as you go.
|
|
86
85
|
|
|
87
86
|
## Adding a language
|
|
88
87
|
|
|
@@ -9,7 +9,7 @@ rails new myapp -a propshaft -j esbuild -c tailwind \
|
|
|
9
9
|
-m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium
|
|
12
|
+
This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium, plus Rodauth auth, asset pipeline, and initial migrations.
|
|
13
13
|
|
|
14
14
|
After the template completes:
|
|
15
15
|
|
|
@@ -62,7 +62,7 @@ For account options and customization, see [Reference › Auth](/reference/auth/
|
|
|
62
62
|
rails generate pu:core:assets
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
Installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers.
|
|
65
|
+
Installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers (`registerControllers(application)`, which your own JS bundle must keep), and points `config.assets.stylesheet` / `script` at your `application` bundles. Until then the app serves the gem's prebuilt CSS and JS, so it is required for custom CSS, brand colors or your own Stimulus controllers. It needs `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` (an app created with `-j esbuild -c tailwind` plus Stimulus). See [Reference › UI › Assets](/reference/ui/assets) and [Guides › Theming](/guides/theming).
|
|
66
66
|
|
|
67
67
|
## Verify
|
|
68
68
|
|
|
@@ -99,6 +99,6 @@ bin/dev
|
|
|
99
99
|
|
|
100
100
|
## Next steps
|
|
101
101
|
|
|
102
|
-
- [Tutorial](./tutorial/)
|
|
103
|
-
- [Adding resources](/guides/adding-resources)
|
|
104
|
-
- [Creating packages](/guides/creating-packages)
|
|
102
|
+
- [Tutorial](./tutorial/): build a complete blog application step-by-step
|
|
103
|
+
- [Adding resources](/guides/adding-resources): create your first resource
|
|
104
|
+
- [Creating packages](/guides/creating-packages): organize code into feature and portal packages
|
|
@@ -118,7 +118,7 @@ rails db:prepare
|
|
|
118
118
|
|
|
119
119
|
## Creating a Portal
|
|
120
120
|
|
|
121
|
-
Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away
|
|
121
|
+
Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away; we'll add authentication in [Chapter 3](./03-authentication).
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
124
|
rails generate pu:pkg:portal admin --public
|
|
@@ -149,9 +149,9 @@ Visit `http://localhost:3000/admin/blogging/posts`. You should see an empty post
|
|
|
149
149
|
|
|
150
150
|

|
|
151
151
|
|
|
152
|
-
Click "New"
|
|
152
|
+
Click "New": the form is automatically generated from your model's attributes. By default Plutonium opens it as a slideover (right) so you keep the index visible; visiting `/admin/blogging/posts/new` directly renders the same form as a standalone page (left):
|
|
153
153
|
|
|
154
|
-
| Default
|
|
154
|
+
| Default: slideover from index | Standalone page (direct URL) |
|
|
155
155
|
|:--:|:--:|
|
|
156
156
|
|  |  |
|
|
157
157
|
|
|
@@ -14,7 +14,7 @@ Plutonium integrates Rodauth seamlessly with its portal system.
|
|
|
14
14
|
|
|
15
15
|
## Installing Rodauth
|
|
16
16
|
|
|
17
|
-
Run the Plutonium Rodauth installer once per app
|
|
17
|
+
Run the Plutonium Rodauth installer once per app. It creates the Rodauth app, plugin, and initializer:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
rails generate pu:rodauth:install
|
|
@@ -24,7 +24,7 @@ rails generate pu:rodauth:install
|
|
|
24
24
|
|
|
25
25
|
## Creating an Account Type
|
|
26
26
|
|
|
27
|
-
Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator
|
|
27
|
+
Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator. It's a preset on top of `pu:rodauth:account` that enables 2FA, lockout, audit logging, and disables public signup:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
30
|
rails generate pu:rodauth:admin admin
|
|
@@ -71,8 +71,8 @@ rails generate pu:pkg:portal admin --auth=admin --force
|
|
|
71
71
|
|
|
72
72
|
This updates two files:
|
|
73
73
|
|
|
74
|
-
- `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb
|
|
75
|
-
- `packages/admin_portal/config/routes.rb
|
|
74
|
+
- `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb`: swaps `include Plutonium::Auth::Public` for `include Plutonium::Auth::Rodauth(:admin)`, giving you `current_user`, `logout_url`, and `profile_url` helpers throughout the portal.
|
|
75
|
+
- `packages/admin_portal/config/routes.rb`: wraps the engine mount in a routes-level constraint:
|
|
76
76
|
|
|
77
77
|
```ruby
|
|
78
78
|
constraints Rodauth::Rails.authenticate(:admin) do
|
|
@@ -80,9 +80,9 @@ This updates two files:
|
|
|
80
80
|
end
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
The routes constraint is what actually gates access
|
|
83
|
+
The routes constraint is what actually gates access: unauthenticated requests to `/admin/*` are redirected to `/admins/login` before they hit any controller or policy.
|
|
84
84
|
|
|
85
|
-
(If you prefer not to regenerate, you can apply both edits by hand
|
|
85
|
+
(If you prefer not to regenerate, you can apply both edits by hand; they're shown above.)
|
|
86
86
|
|
|
87
87
|
## Testing Authentication
|
|
88
88
|
|
|
@@ -164,7 +164,7 @@ Now we can add the author relationship to our Post model. Generate a migration:
|
|
|
164
164
|
rails generate migration AddUserToBloggingPosts user:belongs_to
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
-
Update the migration to add the foreign key:
|
|
167
|
+
Update the migration to add the foreign key (keep the `Migration[x.y]` version Rails generated for your app):
|
|
168
168
|
|
|
169
169
|
```ruby
|
|
170
170
|
class AddUserToBloggingPosts < ActiveRecord::Migration[8.0]
|
|
@@ -28,8 +28,13 @@ Let's implement basic CRUD permissions:
|
|
|
28
28
|
|
|
29
29
|
```ruby
|
|
30
30
|
class Blogging::PostPolicy < Blogging::ResourcePolicy
|
|
31
|
-
# Anyone can
|
|
31
|
+
# Anyone can open the list; relation_scope (below) decides which posts it holds
|
|
32
32
|
def read?
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A single post: published, or your own
|
|
37
|
+
def show?
|
|
33
38
|
record.published? || owner?
|
|
34
39
|
end
|
|
35
40
|
|
|
@@ -56,6 +61,8 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
|
|
|
56
61
|
end
|
|
57
62
|
```
|
|
58
63
|
|
|
64
|
+
`read?` also backs `index?`, which is checked against the `Blogging::Post` class rather than a post, so `record.published?` there would raise `NoMethodError` on the list page. Record-state checks go in `show?`, `update?` and `destroy?`, which always receive the post.
|
|
65
|
+
|
|
59
66
|
## Understanding the Policy Context
|
|
60
67
|
|
|
61
68
|
Inside a policy, you have access to:
|
|
@@ -63,13 +70,13 @@ Inside a policy, you have access to:
|
|
|
63
70
|
| Accessor | Description |
|
|
64
71
|
|----------|-------------|
|
|
65
72
|
| `user` | The current authenticated user |
|
|
66
|
-
| `record` | The resource being authorized |
|
|
73
|
+
| `record` | The resource being authorized (the resource class on collection routes such as index and new) |
|
|
67
74
|
| `entity_scope` | Parent record for scoping (e.g., Organization in multi-tenant apps) |
|
|
68
75
|
|
|
69
76
|
```ruby
|
|
70
77
|
def some_permission?
|
|
71
78
|
user # => Current user (from authentication)
|
|
72
|
-
record # => The Post instance being checked
|
|
79
|
+
record # => The Post instance being checked (Blogging::Post on index/new)
|
|
73
80
|
entity_scope # => Parent record for multi-tenancy (or nil)
|
|
74
81
|
end
|
|
75
82
|
```
|
|
@@ -101,6 +108,12 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
|
|
|
101
108
|
[:title] # Limited view for unpublished posts
|
|
102
109
|
end
|
|
103
110
|
end
|
|
111
|
+
|
|
112
|
+
# The table is built from the class, not a post, so this must not touch `record`
|
|
113
|
+
# (without it, index falls back to permitted_attributes_for_read and raises)
|
|
114
|
+
def permitted_attributes_for_index
|
|
115
|
+
[:title, :published, :created_at, :user]
|
|
116
|
+
end
|
|
104
117
|
end
|
|
105
118
|
```
|
|
106
119
|
|
|
@@ -108,7 +121,7 @@ end
|
|
|
108
121
|
|
|
109
122
|
Control which records appear in listings:
|
|
110
123
|
|
|
111
|
-
`relation_scope` is a macro
|
|
124
|
+
`relation_scope` is a macro: it takes a block. Writing it as a plain instance
|
|
112
125
|
method (`def relation_scope(relation)`) overrides nothing and your scoping is
|
|
113
126
|
silently ignored, so Plutonium raises if you try.
|
|
114
127
|
|
|
@@ -148,6 +161,10 @@ class AdminPortal::Blogging::PostPolicy < ::Blogging::PostPolicy
|
|
|
148
161
|
true
|
|
149
162
|
end
|
|
150
163
|
|
|
164
|
+
def show?
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
151
168
|
def update?
|
|
152
169
|
true
|
|
153
170
|
end
|
|
@@ -79,11 +79,11 @@ end
|
|
|
79
79
|
|
|
80
80
|
## Testing the Action
|
|
81
81
|
|
|
82
|
-
Open any unpublished post and click **Actions** in the top-right
|
|
82
|
+
Open any unpublished post and click **Actions** in the top-right; the "Publish Post" item appears with its Tabler icon:
|
|
83
83
|
|
|
84
84
|

|
|
85
85
|
|
|
86
|
-
It also shows on each table row's `⋮` menu
|
|
86
|
+
It also shows on each table row's `⋮` menu, same action, available wherever the record is rendered:
|
|
87
87
|
|
|
88
88
|

|
|
89
89
|
|
|
@@ -13,8 +13,11 @@ Nested resources are resources that belong to a parent resource. In our blog:
|
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
rails generate pu:res:scaffold Comment body:text user:belongs_to Blogging/Post:belongs_to --dest=blogging
|
|
16
|
+
rails db:prepare
|
|
16
17
|
```
|
|
17
18
|
|
|
19
|
+
Run the migration before connecting the resource to a portal: `pu:res:conn` reads the table's columns to seed the policy's attribute lists.
|
|
20
|
+
|
|
18
21
|
## Setting Up the Association
|
|
19
22
|
|
|
20
23
|
Update the Post model to add the `has_many` association:
|
|
@@ -69,11 +72,11 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
|
|
|
69
72
|
end
|
|
70
73
|
```
|
|
71
74
|
|
|
72
|
-
The post show page now has tabs
|
|
75
|
+
The post show page now has tabs, **Details** and **Comments**, driven by the associations you permit:
|
|
73
76
|
|
|
74
77
|

|
|
75
78
|
|
|
76
|
-
Clicking **Comments** opens the nested index for that post
|
|
79
|
+
Clicking **Comments** opens the nested index for that post: a complete sub-resource view with its own paginated table, "New" button, and row actions:
|
|
77
80
|
|
|
78
81
|

|
|
79
82
|
|
|
@@ -168,11 +168,11 @@ Now you have two portals:
|
|
|
168
168
|
| Admin | `/admin` | Admin | All posts |
|
|
169
169
|
| Author | `/author` | User | Own posts only |
|
|
170
170
|
|
|
171
|
-
Log in at `/users/login` with the user account and you land on the Author Portal dashboard
|
|
171
|
+
Log in at `/users/login` with the user account and you land on the Author Portal dashboard, the same chrome as the Admin Portal but mounted at `/author`, gated by `Rodauth::Rails.authenticate(:user)`:
|
|
172
172
|
|
|
173
173
|

|
|
174
174
|
|
|
175
|
-
The posts list lives at `/author/blogging/posts
|
|
175
|
+
The posts list lives at `/author/blogging/posts`: same `Blogging::Post` resource, different portal context (and once you add the scoping policy below, scoped to the logged-in author):
|
|
176
176
|
|
|
177
177
|

|
|
178
178
|
|
|
@@ -112,39 +112,39 @@ class Blogging::PostDefinition < Blogging::ResourceDefinition
|
|
|
112
112
|
index_page_title "Blog Posts"
|
|
113
113
|
index_page_description "Manage your blog content"
|
|
114
114
|
|
|
115
|
-
show_page_title { |record| record.title }
|
|
116
115
|
show_page_description "View post details"
|
|
117
116
|
end
|
|
118
117
|
```
|
|
119
118
|
|
|
119
|
+
These setters take a literal, or the definition's lazy `t("some.key")` to translate per request. They have no record context, so a title built from the record goes in a `page_title` override on the page class (below).
|
|
120
|
+
|
|
120
121
|
The default "Posts" heading becomes your branded title and description:
|
|
121
122
|
|
|
122
123
|

|
|
123
124
|
|
|
124
|
-
For more advanced customization,
|
|
125
|
+
For more advanced customization, override the page class nested in the definition:
|
|
125
126
|
|
|
126
127
|
```ruby
|
|
127
|
-
|
|
128
|
-
class
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def page_title
|
|
132
|
-
"Blog Posts"
|
|
133
|
-
end
|
|
128
|
+
class Blogging::PostDefinition < Blogging::ResourceDefinition
|
|
129
|
+
class ShowPage < ShowPage
|
|
130
|
+
private
|
|
134
131
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
def page_title
|
|
133
|
+
object.title
|
|
134
|
+
end
|
|
138
135
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
# Add content after the page header
|
|
137
|
+
def render_after_page_header
|
|
138
|
+
div(class: "pu-alert pu-alert-info", role: "status") do
|
|
139
|
+
div(class: "pu-alert-message") { t("blogging.posts.show.comment_count", count: object.comments.count) }
|
|
140
|
+
end
|
|
143
141
|
end
|
|
144
142
|
end
|
|
145
143
|
end
|
|
146
144
|
```
|
|
147
145
|
|
|
146
|
+
`pu-alert pu-alert-<success|warning|danger|info>` is the banner the flash messages use, with dark-mode colors built in. The text comes from a locale key (see [i18n](/reference/i18n#your-own-components-and-pages)).
|
|
147
|
+
|
|
148
148
|
## Custom Form Layout
|
|
149
149
|
|
|
150
150
|
Control form layout using wrapper options in definitions:
|
|
@@ -171,20 +171,35 @@ end
|
|
|
171
171
|
|
|
172
172
|
## Theming with TailwindCSS
|
|
173
173
|
|
|
174
|
-
Plutonium uses TailwindCSS 4.
|
|
174
|
+
Plutonium uses TailwindCSS 4. Out of the box the app serves the gem's prebuilt CSS, so first switch it to your own build:
|
|
175
175
|
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
```bash
|
|
177
|
+
rails generate pu:core:assets
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Brand colors are Tailwind palette colors compiled into the CSS, so change them in the generated `tailwind.config.js` through `plutoniumTailwindConfig.merge`, then rebuild:
|
|
181
|
+
|
|
182
|
+
```javascript
|
|
183
|
+
// tailwind.config.js
|
|
184
|
+
theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
|
|
185
|
+
extend: {
|
|
186
|
+
colors: {
|
|
187
|
+
primary: { 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' }, // Indigo
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
```
|
|
180
192
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
193
|
+
Surfaces, borders, radii and shadows are `--pu-*` tokens. Override them after the Plutonium import in `app/assets/stylesheets/application.tailwind.css`, and repeat every one you change in a `.dark` block so dark mode gets its own value:
|
|
194
|
+
|
|
195
|
+
```css
|
|
196
|
+
:root {
|
|
197
|
+
--pu-radius-md: 0.5rem;
|
|
198
|
+
--pu-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
199
|
+
}
|
|
185
200
|
|
|
186
|
-
|
|
187
|
-
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.
|
|
201
|
+
.dark {
|
|
202
|
+
--pu-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
|
|
188
203
|
}
|
|
189
204
|
```
|
|
190
205
|
|
|
@@ -201,9 +216,9 @@ class StatusBadge < Plutonium::UI::Component::Base
|
|
|
201
216
|
|
|
202
217
|
def view_template
|
|
203
218
|
if @published
|
|
204
|
-
span(class: "
|
|
219
|
+
span(class: "pu-badge pu-badge-success") { t("blogging.posts.status.published") }
|
|
205
220
|
else
|
|
206
|
-
span(class: "
|
|
221
|
+
span(class: "pu-badge pu-badge-warning") { t("blogging.posts.status.draft") }
|
|
207
222
|
end
|
|
208
223
|
end
|
|
209
224
|
end
|
|
@@ -224,7 +239,7 @@ class CustomLayout < Plutonium::UI::Layout::ResourceLayout
|
|
|
224
239
|
|
|
225
240
|
# Customize body classes
|
|
226
241
|
def body_attributes
|
|
227
|
-
{class: "antialiased min-h-
|
|
242
|
+
{class: "antialiased pu-min-h-viewport bg-[var(--pu-surface)] text-[var(--pu-text)]"}
|
|
228
243
|
end
|
|
229
244
|
|
|
230
245
|
# Add content before the main section
|
|
@@ -60,7 +60,7 @@ Customize forms, tables, and views to match your requirements.
|
|
|
60
60
|
If you get stuck:
|
|
61
61
|
|
|
62
62
|
- Check the [Guides](/guides/) for task-oriented walkthroughs.
|
|
63
|
-
- Browse the [Reference](/reference/) for full API surface
|
|
63
|
+
- Browse the [Reference](/reference/) for full API surface: [App](/reference/app/), [Resource](/reference/resource/), [Behavior](/reference/behavior/), [UI](/reference/ui/), [Auth](/reference/auth/), [Tenancy](/reference/tenancy/), [Testing](/reference/testing/).
|
|
64
64
|
- Visit [GitHub Issues](https://github.com/radioactive-labs/plutonium-core/issues).
|
|
65
65
|
|
|
66
66
|
[Begin Chapter 1: Project Setup →](./01-setup)
|
|
@@ -40,6 +40,8 @@ rails g pu:res:conn Post --dest=admin_portal
|
|
|
40
40
|
|
|
41
41
|
This creates the portal-specific controller, policy, and definition, plus registers the resource in the portal's routes. Until you do this, the resource has no URL.
|
|
42
42
|
|
|
43
|
+
Run it after the migration. When there is no base policy to inherit from, `pu:res:conn` seeds the policy's attribute lists from the table's columns; on an unmigrated table it logs an error and writes empty lists.
|
|
44
|
+
|
|
43
45
|
For singular resources (`/profile`, `/settings`), add `--singular`:
|
|
44
46
|
|
|
45
47
|
```bash
|
|
@@ -48,7 +50,7 @@ rails g pu:res:conn Profile --dest=customer_portal --singular
|
|
|
48
50
|
|
|
49
51
|
### 5. Trim the generated policy
|
|
50
52
|
|
|
51
|
-
The generator is liberal
|
|
53
|
+
The generator is liberal: it seeds `permitted_attributes_for_*` from your model columns. Open `packages/admin_portal/app/policies/admin_portal/post_policy.rb` and:
|
|
52
54
|
|
|
53
55
|
- Drop `_id` fields when the form should use the association name (e.g. `:user`, not `:user_id`).
|
|
54
56
|
- Replace `:price_cents` with `:price` if the model uses `has_cents`.
|
|
@@ -83,7 +85,7 @@ You should see:
|
|
|
83
85
|
|
|
84
86
|
Two paths:
|
|
85
87
|
|
|
86
|
-
**Migration only.** Add a new column with a standard Rails migration. Plutonium auto-detects it
|
|
88
|
+
**Migration only.** Add a new column with a standard Rails migration. Plutonium auto-detects it and shows it in all CRUD pages.
|
|
87
89
|
|
|
88
90
|
**Field with custom rendering.** Add the column, then declare it in the definition:
|
|
89
91
|
|
|
@@ -117,6 +119,7 @@ rails g pu:res:conn Post --dest=admin_portal
|
|
|
117
119
|
|
|
118
120
|
```bash
|
|
119
121
|
rails g pu:res:scaffold Blogging::Post title:string --dest=blogging
|
|
122
|
+
rails db:prepare
|
|
120
123
|
rails g pu:res:conn Blogging::Post --dest=admin_portal
|
|
121
124
|
```
|
|
122
125
|
|
|
@@ -132,8 +135,8 @@ The `blogging/post` syntax expands to `Blogging::Post`.
|
|
|
132
135
|
|
|
133
136
|
## Related
|
|
134
137
|
|
|
135
|
-
- [Reference › App › Generators](/reference/app/generators)
|
|
136
|
-
- [Reference › Resource](/reference/resource/)
|
|
137
|
-
- [Reference › App › Portals](/reference/app/portals)
|
|
138
|
-
- [Creating packages](./creating-packages)
|
|
139
|
-
- [Nested resources](./nested-resources)
|
|
138
|
+
- [Reference › App › Generators](/reference/app/generators): full generator catalog
|
|
139
|
+
- [Reference › Resource](/reference/resource/): model + definition + query + actions
|
|
140
|
+
- [Reference › App › Portals](/reference/app/portals): `pu:res:conn` details
|
|
141
|
+
- [Creating packages](./creating-packages): resources in feature packages
|
|
142
|
+
- [Nested resources](./nested-resources): parent/child relationships
|