plutonium 0.51.0 → 0.53.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-app/SKILL.md +2 -0
- data/.claude/skills/plutonium-auth/SKILL.md +6 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +1 -1
- data/.claude/skills/plutonium-resource/SKILL.md +6 -4
- data/.claude/skills/plutonium-tenancy/SKILL.md +31 -7
- data/.claude/skills/plutonium-testing/SKILL.md +3 -1
- data/.claude/skills/plutonium-ui/SKILL.md +32 -8
- data/CHANGELOG.md +33 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +258 -11
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +39 -39
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +2 -1
- data/docs/.vitepress/config.ts +2 -2
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -50
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/guides/authentication.md +11 -6
- data/docs/guides/authorization.md +3 -3
- data/docs/guides/creating-packages.md +8 -11
- data/docs/guides/custom-actions.md +8 -2
- data/docs/guides/customizing-ui.md +259 -0
- data/docs/guides/index.md +49 -32
- data/docs/guides/multi-tenancy.md +14 -6
- data/docs/guides/nested-resources.md +69 -0
- data/docs/guides/search-filtering.md +6 -0
- data/docs/guides/testing.md +5 -1
- data/docs/guides/theming.md +14 -1
- data/docs/guides/user-invites.md +10 -4
- data/docs/guides/user-profile.md +8 -0
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/components/avatar.png +0 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- data/docs/reference/app/generators.md +4 -4
- data/docs/reference/auth/accounts.md +7 -8
- data/docs/reference/auth/index.md +1 -1
- data/docs/reference/behavior/policies.md +2 -2
- data/docs/reference/configuration.md +61 -0
- data/docs/reference/index.md +67 -55
- data/docs/reference/resource/actions.md +2 -1
- data/docs/reference/resource/definition.md +5 -4
- data/docs/reference/tenancy/entity-scoping.md +14 -8
- data/docs/reference/tenancy/index.md +1 -1
- data/docs/reference/tenancy/invites.md +12 -5
- data/docs/reference/ui/components.md +53 -0
- data/docs/reference/ui/forms.md +1 -1
- data/docs/reference/ui/pages.md +6 -5
- data/docs/reference/ui/tables.md +8 -4
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
- data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
- data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/invites/install_generator.rb +44 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- data/lib/plutonium/action/base.rb +43 -63
- data/lib/plutonium/configuration.rb +7 -0
- data/lib/plutonium/definition/actions.rb +10 -11
- data/lib/plutonium/definition/base.rb +29 -0
- data/lib/plutonium/helpers/assets_helper.rb +0 -30
- data/lib/plutonium/helpers/content_helper.rb +0 -44
- data/lib/plutonium/helpers/display_helper.rb +0 -62
- data/lib/plutonium/helpers/turbo_helper.rb +17 -2
- data/lib/plutonium/helpers.rb +0 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +4 -4
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- data/lib/plutonium/resource/definition.rb +0 -42
- data/lib/plutonium/ui/action_button.rb +4 -3
- data/lib/plutonium/ui/avatar.rb +182 -0
- data/lib/plutonium/ui/component/kit.rb +2 -0
- data/lib/plutonium/ui/component/methods.rb +1 -0
- data/lib/plutonium/ui/form/base.rb +32 -2
- data/lib/plutonium/ui/form/components/secure_association.rb +14 -8
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +58 -0
- data/lib/plutonium/ui/form/theme.rb +8 -4
- data/lib/plutonium/ui/grid/card.rb +10 -26
- data/lib/plutonium/ui/modal/base.rb +36 -1
- data/lib/plutonium/ui/modal/centered.rb +24 -6
- data/lib/plutonium/ui/modal/slideover.rb +26 -11
- data/lib/plutonium/ui/nav_user.rb +3 -23
- data/lib/plutonium/ui/page/edit.rb +7 -4
- data/lib/plutonium/ui/page/interactive_action.rb +5 -3
- data/lib/plutonium/ui/page/new.rb +7 -4
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
- data/lib/plutonium/version.rb +1 -1
- data/package.json +4 -1
- data/src/css/components.css +38 -1
- data/src/css/slim_select.css +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +165 -0
- data/src/js/controllers/form_controller.js +5 -4
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +53 -19
- data/src/js/turbo/index.js +1 -0
- data/src/js/turbo/turbo_confirm.js +128 -0
- data/yarn.lock +108 -1
- metadata +52 -6
- data/lib/plutonium/helpers/attachment_helper.rb +0 -73
- data/lib/plutonium/helpers/table_helper.rb +0 -35
- /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
|
@@ -185,7 +185,7 @@ rails rodauth_admin:create[admin@example.com,password123]
|
|
|
185
185
|
|
|
186
186
|
```bash
|
|
187
187
|
rails g pu:saas:setup --user Customer --entity Organization
|
|
188
|
-
rails g pu:saas:setup --user Customer --entity Organization --roles=member
|
|
188
|
+
rails g pu:saas:setup --user Customer --entity Organization --roles=admin,member
|
|
189
189
|
rails g pu:saas:setup --user Customer --entity Organization --no-allow-signup
|
|
190
190
|
rails g pu:saas:setup --user Customer --entity Organization \
|
|
191
191
|
--user-attributes=name:string --entity-attributes=slug:string
|
|
@@ -216,7 +216,7 @@ For when you don't want the full `pu:saas:setup` meta-generator:
|
|
|
216
216
|
```bash
|
|
217
217
|
rails g pu:saas:user Customer
|
|
218
218
|
rails g pu:saas:entity Organization --extra-attributes=slug:string
|
|
219
|
-
rails g pu:saas:membership --user Customer --entity Organization --roles=member
|
|
219
|
+
rails g pu:saas:membership --user Customer --entity Organization --roles=admin,member
|
|
220
220
|
rails g pu:saas:portal customer --entity Organization
|
|
221
221
|
rails g pu:saas:welcome --user Customer --entity Organization
|
|
222
222
|
```
|
|
@@ -305,10 +305,10 @@ rails g pu:invites:install --entity-model=Organization --user-model=Customer --i
|
|
|
305
305
|
| `--entity-model=NAME` | `Entity` | Entity model name |
|
|
306
306
|
| `--user-model=NAME` | `User` | User model name |
|
|
307
307
|
| `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name |
|
|
308
|
-
| `--membership-model=NAME` | `EntityUser` | Membership join model |
|
|
309
|
-
| `--roles` | `member,admin` | Comma-separated |
|
|
308
|
+
| `--membership-model=NAME` | `EntityUser` | Membership join model (must already exist; roles read from its `enum :role`) |
|
|
310
309
|
| `--rodauth=NAME` | `user` | Rodauth configuration for signup |
|
|
311
310
|
| `--enforce-domain` | `false` | Require email domain to match entity |
|
|
311
|
+
| `--dest=PACKAGE` | `main_app` | Package where the entity model lives (controls where `invite_user_interaction.rb` is generated) |
|
|
312
312
|
|
|
313
313
|
Multiple invite flows are supported — run `pu:invites:install` once per flow.
|
|
314
314
|
|
|
@@ -14,7 +14,6 @@ rails generate pu:rodauth:account user [options]
|
|
|
14
14
|
|---|---|
|
|
15
15
|
| `--defaults` | Enables login, logout, remember, password reset |
|
|
16
16
|
| `--kitchen_sink` | Enables ALL features |
|
|
17
|
-
| `--primary` | Mark as primary account (no URL prefix) |
|
|
18
17
|
| `--no-mails` | Skip mailer setup |
|
|
19
18
|
| `--argon2` | Use Argon2 instead of bcrypt |
|
|
20
19
|
| `--api_only` | JSON API only (no sessions) |
|
|
@@ -50,9 +49,6 @@ rails generate pu:rodauth:account user [options]
|
|
|
50
49
|
# Basic account
|
|
51
50
|
rails g pu:rodauth:account user
|
|
52
51
|
|
|
53
|
-
# Primary account (no URL prefix)
|
|
54
|
-
rails g pu:rodauth:account user --primary
|
|
55
|
-
|
|
56
52
|
# With 2FA
|
|
57
53
|
rails g pu:rodauth:account user --otp --recovery_codes
|
|
58
54
|
|
|
@@ -84,13 +80,16 @@ rails g pu:rodauth:admin admin --extra-attributes=name:string,department:string
|
|
|
84
80
|
enum :role, super_admin: 0, admin: 1
|
|
85
81
|
```
|
|
86
82
|
|
|
87
|
-
Rake task for direct admin creation:
|
|
83
|
+
Rake task for direct admin creation (generated alongside the account — namespace is `rodauth`, task name is the account name):
|
|
88
84
|
|
|
89
85
|
```bash
|
|
90
|
-
|
|
86
|
+
EMAIL=admin@example.com rails rodauth:admin
|
|
87
|
+
# (run without EMAIL to be prompted)
|
|
91
88
|
```
|
|
92
89
|
|
|
93
|
-
|
|
90
|
+
The task creates the account and triggers a verification email; the admin sets their own password via that flow. No password is passed on the command line.
|
|
91
|
+
|
|
92
|
+
## SaaS setup — `pu:saas:setup` (meta-generator) {#saas-setup}
|
|
94
93
|
|
|
95
94
|
Creates the User + Entity + Membership trio AND runs:
|
|
96
95
|
|
|
@@ -105,7 +104,7 @@ After `pu:saas:setup` runs, don't separately run `pu:saas:portal`, `pu:profile:s
|
|
|
105
104
|
|
|
106
105
|
```bash
|
|
107
106
|
rails g pu:saas:setup --user Customer --entity Organization
|
|
108
|
-
rails g pu:saas:setup --user Customer --entity Organization --roles=member
|
|
107
|
+
rails g pu:saas:setup --user Customer --entity Organization --roles=admin,member
|
|
109
108
|
rails g pu:saas:setup --user Customer --entity Organization --no-allow-signup
|
|
110
109
|
rails g pu:saas:setup --user Customer --entity Organization \
|
|
111
110
|
--user-attributes=name:string --entity-attributes=slug:string
|
|
@@ -43,7 +43,7 @@ class AdminController < PlutoniumController
|
|
|
43
43
|
end
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
`Plutonium::Auth::Rodauth(:name)` exposes `current_user`, `logout_url`, and `rodauth` in the controller.
|
|
46
|
+
`Plutonium::Auth::Rodauth(:name)` exposes `current_user`, `logout_url`, `profile_url`, and `rodauth` in the controller (all available as helper methods in views too).
|
|
47
47
|
|
|
48
48
|
For portal wiring (`AdminPortal::Concerns::Controller`), see [App › Portals](/reference/app/portals#controller-concern-auth).
|
|
49
49
|
|
|
@@ -11,7 +11,7 @@ Authorization for resources. Built on [ActionPolicy](https://actionpolicy.evilma
|
|
|
11
11
|
|
|
12
12
|
- **`create?` and `read?` default to `false`.** You MUST override them explicitly. Everything else (`update?`, `destroy?`, `index?`, `show?`, …) derives from one of those.
|
|
13
13
|
- **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detects; production raises.
|
|
14
|
-
- **`relation_scope` must
|
|
14
|
+
- **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly in your override. `super` is fine when extending a parent policy (e.g., a package base) that itself calls it. The runtime check verifies it was hit somewhere — not in this specific class.
|
|
15
15
|
- **For `has_cents` fields, use the virtual name** (`:price`), NEVER `:price_cents`.
|
|
16
16
|
- **Don't put `*_attributes` hashes in `permitted_attributes_for_*`.** Nested forms are extracted from the form definition, not the policy. List the association name (`:variants`) and the `nested_input` in the definition handles the rest.
|
|
17
17
|
- **Custom action ⇒ policy method.** `action :publish` needs `def publish?`. Undefined methods return `false` → action silently disappears.
|
|
@@ -247,7 +247,7 @@ Filter which records the user can see.
|
|
|
247
247
|
|
|
248
248
|
### Always compose with `default_relation_scope`
|
|
249
249
|
|
|
250
|
-
🚨 `relation_scope` MUST
|
|
250
|
+
🚨 `relation_scope` MUST end up calling `default_relation_scope(relation)` somewhere in the chain. `super` works — `Plutonium::Resource::Policy` defines a default scope block that calls `default_relation_scope`, so a subclass that does `super(relation).where(...)` is fine. Calling `default_relation_scope` explicitly is also fine (and required when you skip the parent chain). Plutonium enforces this at runtime via `verify_default_relation_scope_applied!`.
|
|
251
251
|
|
|
252
252
|
```ruby
|
|
253
253
|
# ✅ Best — don't override at all. The inherited scope already calls default_relation_scope.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
Plutonium is configured through `Plutonium.configure` in an initializer. A generated app has this at `config/initializers/plutonium.rb`:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# config/initializers/plutonium.rb
|
|
7
|
+
Plutonium.configure do |config|
|
|
8
|
+
config.load_defaults 1.0
|
|
9
|
+
|
|
10
|
+
# config.shell = :modern
|
|
11
|
+
# config.navii_host_url = "https://api.navii.dev"
|
|
12
|
+
|
|
13
|
+
config.assets.logo = "plutonium.png"
|
|
14
|
+
config.assets.favicon = "plutonium.ico"
|
|
15
|
+
config.assets.stylesheet = "plutonium.css"
|
|
16
|
+
config.assets.script = "plutonium.min.js"
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Access the live config anywhere via `Plutonium.configuration`.
|
|
21
|
+
|
|
22
|
+
## Versioned defaults
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
config.load_defaults 1.0
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Loads the baseline defaults for a given framework version. Call this first; later versions layer their changes on top. Read the resolved version with `config.defaults_version`.
|
|
29
|
+
|
|
30
|
+
## Options
|
|
31
|
+
|
|
32
|
+
| Option | Default | Description |
|
|
33
|
+
|--------|---------|-------------|
|
|
34
|
+
| `load_defaults(version)` | — | Apply versioned framework defaults. Call first. |
|
|
35
|
+
| `development` | `ENV["PLUTONIUM_DEV"]` | Development mode for the framework itself (local assets, hot reload, verbose errors). Query with `config.development?`. You rarely set this in an app — see [Development mode](#development-mode). |
|
|
36
|
+
| `cache_discovery` | `true` outside `development` env | Cache resource/route discovery. Disable to pick up new resources without a reboot. |
|
|
37
|
+
| `enable_hotreload` | `true` in `development` env | Hot-reload Plutonium components on change. |
|
|
38
|
+
| `shell` | `:modern` | Chrome style: `:modern` (topbar + icon rail) or `:classic` (legacy header + sidebar, only for upgrades). See [Layouts](./ui/layouts). |
|
|
39
|
+
| `navii_host_url` | `"https://api.navii.dev"` | Host of the [Navii](https://navii.dev) avatar service used by [`Avatar`](./ui/components#avatar). The component appends `/avatar/:seed`. Repoint to self-host or proxy. |
|
|
40
|
+
| `assets.logo` | `"plutonium.png"` | Brand logo asset. See [Assets](./ui/assets). |
|
|
41
|
+
| `assets.favicon` | `"plutonium.ico"` | Favicon asset. |
|
|
42
|
+
| `assets.stylesheet` | `"plutonium.css"` | Stylesheet entry. |
|
|
43
|
+
| `assets.script` | `"plutonium.min.js"` | JavaScript entry. |
|
|
44
|
+
|
|
45
|
+
## Development mode
|
|
46
|
+
|
|
47
|
+
`config.development?` is driven by the `PLUTONIUM_DEV` environment variable, not set in the initializer. It’s primarily for working **on the Plutonium gem** (uses local `src/` assets, enables hot reloading, and shows more detailed errors). Applications generally leave it unset.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export PLUTONIUM_DEV=1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Assets
|
|
54
|
+
|
|
55
|
+
Asset entries live under `config.assets` and point the framework at your compiled stylesheet/script and brand imagery. The `pu:core:assets` generator wires these up. See [Assets](./ui/assets) for the full asset/Tailwind/Stimulus setup.
|
|
56
|
+
|
|
57
|
+
## Related
|
|
58
|
+
|
|
59
|
+
- [Assets](./ui/assets) — stylesheet, script, Tailwind, and design tokens
|
|
60
|
+
- [Layouts](./ui/layouts) — the `shell` option and ejecting chrome
|
|
61
|
+
- [Components › Avatar](./ui/components#avatar) — `navii_host_url`
|
data/docs/reference/index.md
CHANGED
|
@@ -1,56 +1,68 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
layout: page
|
|
3
|
+
sidebar: false
|
|
4
|
+
aside: false
|
|
5
|
+
---
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
7
|
+
<SectionLanding
|
|
8
|
+
eyebrow="Reference"
|
|
9
|
+
title="Every API, in one place."
|
|
10
|
+
lede="The full surface area of Plutonium — controllers, policies, definitions, fields, interactions, generators."
|
|
11
|
+
mode="categorized"
|
|
12
|
+
:rail="[
|
|
13
|
+
{ group: 'App', items: [
|
|
14
|
+
{ name: 'Overview', link: '/plutonium-core/reference/app/' },
|
|
15
|
+
{ name: 'Packages', link: '/plutonium-core/reference/app/packages' },
|
|
16
|
+
{ name: 'Portals', link: '/plutonium-core/reference/app/portals' },
|
|
17
|
+
{ name: 'Generators', link: '/plutonium-core/reference/app/generators' },
|
|
18
|
+
]},
|
|
19
|
+
{ group: 'Resource', items: [
|
|
20
|
+
{ name: 'Overview', link: '/plutonium-core/reference/resource/' },
|
|
21
|
+
{ name: 'Model', link: '/plutonium-core/reference/resource/model' },
|
|
22
|
+
{ name: 'Definition', link: '/plutonium-core/reference/resource/definition' },
|
|
23
|
+
{ name: 'Query', link: '/plutonium-core/reference/resource/query' },
|
|
24
|
+
{ name: 'Actions', link: '/plutonium-core/reference/resource/actions' },
|
|
25
|
+
]},
|
|
26
|
+
{ group: 'Behavior', items: [
|
|
27
|
+
{ name: 'Overview', link: '/plutonium-core/reference/behavior/' },
|
|
28
|
+
{ name: 'Controllers', link: '/plutonium-core/reference/behavior/controllers' },
|
|
29
|
+
{ name: 'Policies', link: '/plutonium-core/reference/behavior/policies' },
|
|
30
|
+
{ name: 'Interactions', link: '/plutonium-core/reference/behavior/interactions' },
|
|
31
|
+
]},
|
|
32
|
+
{ group: 'UI', items: [
|
|
33
|
+
{ name: 'Overview', link: '/plutonium-core/reference/ui/' },
|
|
34
|
+
{ name: 'Pages', link: '/plutonium-core/reference/ui/pages' },
|
|
35
|
+
{ name: 'Forms', link: '/plutonium-core/reference/ui/forms' },
|
|
36
|
+
{ name: 'Displays', link: '/plutonium-core/reference/ui/displays' },
|
|
37
|
+
{ name: 'Tables', link: '/plutonium-core/reference/ui/tables' },
|
|
38
|
+
{ name: 'Components', link: '/plutonium-core/reference/ui/components' },
|
|
39
|
+
{ name: 'Layouts', link: '/plutonium-core/reference/ui/layouts' },
|
|
40
|
+
{ name: 'Assets', link: '/plutonium-core/reference/ui/assets' },
|
|
41
|
+
]},
|
|
42
|
+
{ group: 'Auth', items: [
|
|
43
|
+
{ name: 'Overview', link: '/plutonium-core/reference/auth/' },
|
|
44
|
+
{ name: 'Accounts', link: '/plutonium-core/reference/auth/accounts' },
|
|
45
|
+
{ name: 'Profile', link: '/plutonium-core/reference/auth/profile' },
|
|
46
|
+
]},
|
|
47
|
+
{ group: 'Tenancy', items: [
|
|
48
|
+
{ name: 'Overview', link: '/plutonium-core/reference/tenancy/' },
|
|
49
|
+
{ name: 'Entity scoping', link: '/plutonium-core/reference/tenancy/entity-scoping' },
|
|
50
|
+
{ name: 'Nested resources', link: '/plutonium-core/reference/tenancy/nested-resources' },
|
|
51
|
+
{ name: 'Invites', link: '/plutonium-core/reference/tenancy/invites' },
|
|
52
|
+
]},
|
|
53
|
+
{ group: 'Testing', items: [
|
|
54
|
+
{ name: 'Overview', link: '/plutonium-core/reference/testing/' },
|
|
55
|
+
]},
|
|
56
|
+
]"
|
|
57
|
+
:sidebar="[
|
|
58
|
+
{ heading: 'Learning?', items: [
|
|
59
|
+
{ label: 'Tutorial', href: '/plutonium-core/getting-started/tutorial/' },
|
|
60
|
+
]},
|
|
61
|
+
{ heading: 'Solving a problem?', items: [
|
|
62
|
+
{ label: 'Guides', href: '/plutonium-core/guides/' },
|
|
63
|
+
]},
|
|
64
|
+
{ heading: 'Need help?', items: [
|
|
65
|
+
{ label: 'GitHub Discussions', href: 'https://github.com/radioactive-labs/plutonium-core/discussions' },
|
|
66
|
+
]},
|
|
67
|
+
]"
|
|
68
|
+
/>
|
|
@@ -70,7 +70,8 @@ action :name,
|
|
|
70
70
|
turbo_frame: "_top",
|
|
71
71
|
return_to: "/custom/path",
|
|
72
72
|
route_options: {action: :foo},
|
|
73
|
-
modal: :slideover
|
|
73
|
+
modal: :slideover, # :slideover / :centered — overrides the definition's modal mode
|
|
74
|
+
size: :lg # :sm / :md / :lg / :xl / :auto / :full — overrides the definition's modal size
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
### Deriving variants — `Action#with(...)`
|
|
@@ -111,7 +111,7 @@ end
|
|
|
111
111
|
|
|
112
112
|
### Display types (show / index)
|
|
113
113
|
|
|
114
|
-
`:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`
|
|
114
|
+
`:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`, `:color`
|
|
115
115
|
|
|
116
116
|
## Field options
|
|
117
117
|
|
|
@@ -410,15 +410,16 @@ class PostDefinition < ResourceDefinition
|
|
|
410
410
|
# false — always hide
|
|
411
411
|
submit_and_continue false
|
|
412
412
|
|
|
413
|
-
# How :new / :edit render
|
|
413
|
+
# How :new / :edit and interactive actions render
|
|
414
414
|
# :slideover (default) — slide-in panel from the right
|
|
415
415
|
# :centered — centered dialog
|
|
416
416
|
# false — full standalone pages (no modal)
|
|
417
|
-
|
|
417
|
+
# size: optional, one of :sm, :md (default), :lg, :xl, :auto, :full
|
|
418
|
+
modal :centered, size: :lg
|
|
418
419
|
end
|
|
419
420
|
```
|
|
420
421
|
|
|
421
|
-
`modal:`
|
|
422
|
+
`modal:` is the default for framework `:new`/`:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win — see [Actions](./actions).
|
|
422
423
|
|
|
423
424
|
## Metadata panel (show page)
|
|
424
425
|
|
|
@@ -4,8 +4,7 @@ Multi-tenant data isolation. Built on three cooperating pieces — portal, polic
|
|
|
4
4
|
|
|
5
5
|
## 🚨 Critical
|
|
6
6
|
|
|
7
|
-
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied
|
|
8
|
-
- **Don't rely on `super`** inside `relation_scope` — call `default_relation_scope(relation)` by name.
|
|
7
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!` — make sure the chain ends up calling `default_relation_scope(relation)` somewhere (explicitly, or via `super` to a parent that calls it — `Plutonium::Resource::Policy` does).
|
|
9
8
|
- **Fix the MODEL, not the policy.** If `associated_with` can't resolve, declare an association path (`belongs_to`, `has_one :through`) OR a custom `associated_with_<entity>` scope on the model. Never paper over it with a `where` in the policy.
|
|
10
9
|
- **Compound uniqueness scoped to the tenant FK** — `validates :code, uniqueness: {scope: :organization_id}`.
|
|
11
10
|
- **Multiple associations to the same entity class** require overriding `scoped_entity_association` on the controller.
|
|
@@ -180,8 +179,8 @@ relation_scope { |r| r.joins(:project).where(projects: {organization_id: current
|
|
|
180
179
|
relation_scope { |r| r.where(published: true) }
|
|
181
180
|
```
|
|
182
181
|
|
|
183
|
-
:::
|
|
184
|
-
`
|
|
182
|
+
::: tip `super` works too
|
|
183
|
+
`Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope(relation)`, so `super(relation)` from a subclass picks it up and the runtime check passes. Use whichever reads more clearly — `super(relation).where(archived: false)` and `default_relation_scope(relation).where(archived: false)` are equivalent when extending the framework base. Call `default_relation_scope` explicitly when you're not chaining via `super` (e.g. replacing the scope entirely).
|
|
185
184
|
:::
|
|
186
185
|
|
|
187
186
|
### Intentionally skipping the scope
|
|
@@ -215,7 +214,7 @@ module CustomerPortal
|
|
|
215
214
|
end
|
|
216
215
|
```
|
|
217
216
|
|
|
218
|
-
Routes become
|
|
217
|
+
Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time — the entity id is the first path segment after the mount). The portal extracts `params[:organization_scoped]` and loads the entity automatically.
|
|
219
218
|
|
|
220
219
|
### Custom strategy (subdomain, session, etc.)
|
|
221
220
|
|
|
@@ -239,11 +238,19 @@ The strategy symbol must match a method name on the controller concern.
|
|
|
239
238
|
|
|
240
239
|
### Custom param key
|
|
241
240
|
|
|
242
|
-
|
|
241
|
+
The default `param_key` derives from the entity class — `<singular_route_key>_scoped` (e.g. `:organization_scoped`) — to avoid colliding with a `belongs_to :organization` on child models when reading `params[:organization]`. The URL itself just uses the entity id as the first segment after the mount:
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
mount CustomerPortal::Engine, at: "/customer"
|
|
245
|
+
# → /customer/:organization_scoped/posts (route definition)
|
|
246
|
+
# → /customer/42/posts (request URL)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Override when you want a different param name (e.g. for readability when reading params in custom controllers):
|
|
243
250
|
|
|
244
251
|
```ruby
|
|
245
252
|
scope_to_entity Organization, strategy: :path, param_key: :org_id
|
|
246
|
-
# →
|
|
253
|
+
# → params[:org_id] instead of params[:organization_scoped]
|
|
247
254
|
```
|
|
248
255
|
|
|
249
256
|
### Accessing the scoped entity
|
|
@@ -346,7 +353,6 @@ Without the scope, uniqueness leaks across tenants — Org A and Org B could col
|
|
|
346
353
|
## Gotchas
|
|
347
354
|
|
|
348
355
|
- **Policy tries to filter by entity directly.** Wrong — bypasses `default_relation_scope`. Add the association path to the model instead.
|
|
349
|
-
- **`super` inside `relation_scope`.** Unreliable. Use `default_relation_scope(relation)` explicitly.
|
|
350
356
|
- **Multiple associations to the same entity class.** Override `scoped_entity_association`.
|
|
351
357
|
- **`param_key` differs from association name.** Fine — Plutonium finds the association by class.
|
|
352
358
|
- **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks across tenants.
|
|
@@ -20,7 +20,7 @@ Configure the portal once. The policy and model conventions then carry tenancy a
|
|
|
20
20
|
|
|
21
21
|
## 🚨 Critical (applies to all three sub-pages)
|
|
22
22
|
|
|
23
|
-
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!`.
|
|
23
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!`. Make sure `default_relation_scope(relation)` is called somewhere in the chain — explicitly here, or via `super` to a parent policy (e.g., a package base) that calls it.
|
|
24
24
|
- **Always declare an association path from the model to the entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, fix the **model**, not the policy.
|
|
25
25
|
- **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, not via `entity_scope`. Don't double-scope.
|
|
26
26
|
- **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
|
|
@@ -36,19 +36,21 @@ rails generate pu:invites:install
|
|
|
36
36
|
| `--entity-model=NAME` | `Entity` | Entity model name |
|
|
37
37
|
| `--user-model=NAME` | `User` | User model name |
|
|
38
38
|
| `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name (omit for single-flow apps) |
|
|
39
|
-
| `--membership-model=NAME` | `EntityUser` | Membership join model |
|
|
40
|
-
| `--roles` | `member,admin` | Comma-separated roles |
|
|
39
|
+
| `--membership-model=NAME` | `EntityUser` | Membership join model (must already exist) |
|
|
41
40
|
| `--rodauth=NAME` | `user` | Rodauth configuration for signup |
|
|
42
41
|
| `--enforce-domain` | `false` | Require invited email domain to match entity domain |
|
|
43
42
|
|
|
43
|
+
::: info Roles come from the membership model
|
|
44
|
+
The role list is read from the membership model's `enum :role` — there is no `--roles=` flag on `pu:invites:install`. Set roles when generating the membership model (`pu:saas:membership --roles=...`) or edit its enum directly. **Index 0 is the most privileged** (typically `owner`, which the invite UI excludes from selectable choices); new invitees default to the second role.
|
|
45
|
+
:::
|
|
46
|
+
|
|
44
47
|
Example with custom models:
|
|
45
48
|
|
|
46
49
|
```bash
|
|
47
50
|
rails g pu:invites:install \
|
|
48
51
|
--entity-model=Organization \
|
|
49
52
|
--user-model=Customer \
|
|
50
|
-
--membership-model=OrganizationMember
|
|
51
|
-
--roles=member,manager,admin
|
|
53
|
+
--membership-model=OrganizationMember
|
|
52
54
|
```
|
|
53
55
|
|
|
54
56
|
After install:
|
|
@@ -317,10 +319,15 @@ Requires the invited email's domain to match the entity's domain.
|
|
|
317
319
|
|
|
318
320
|
### Custom roles
|
|
319
321
|
|
|
322
|
+
Roles are defined on the membership model, not on the invites generator. Set them at membership generation time (ordering matters — **index 0 is the most privileged**, typically `owner`):
|
|
323
|
+
|
|
320
324
|
```bash
|
|
321
|
-
rails g pu:
|
|
325
|
+
rails g pu:saas:membership --user Customer --entity Organization --roles=admin,editor,viewer
|
|
326
|
+
# → enum :role, { owner: 0, admin: 1, editor: 2, viewer: 3 } (owner is auto-prepended)
|
|
322
327
|
```
|
|
323
328
|
|
|
329
|
+
Or edit `enum :role` on the existing membership model directly. Then run `pu:invites:install`.
|
|
330
|
+
|
|
324
331
|
### Custom expiration
|
|
325
332
|
|
|
326
333
|
Override on the model:
|
|
@@ -10,6 +10,7 @@ Inside any `Plutonium::UI::Component::Base` subclass (or any page/form/display c
|
|
|
10
10
|
PageHeader(title: "Dashboard", description: "...", actions: [...])
|
|
11
11
|
Panel(class: "mt-4") { p { "Content" } }
|
|
12
12
|
Block { TabList(items: tabs) }
|
|
13
|
+
Avatar(user)
|
|
13
14
|
EmptyCard("No items found")
|
|
14
15
|
ActionButton(action, url: "/posts/new")
|
|
15
16
|
DynaFrameHost(src: "/some/path", loading: :lazy)
|
|
@@ -23,6 +24,58 @@ Breadcrumbs()
|
|
|
23
24
|
|
|
24
25
|
These are shorthand for `render Plutonium::UI::PageHeader.new(...)` etc. — they work because every component class is exposed as a method on `Plutonium::UI::Component::Base`.
|
|
25
26
|
|
|
27
|
+
## Avatar
|
|
28
|
+
|
|
29
|
+
`Plutonium::UI::Avatar` renders a profile image for a subject. It resolves an optional image source and falls back to a deterministic avatar from the hosted [Navii](https://navii.dev) service, then to a generic user icon when there's nothing to show.
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
Avatar(user) # Navii fallback seeded from the record
|
|
35
|
+
Avatar(user, src: :avatar) # user.avatar if present, else Navii fallback
|
|
36
|
+
Avatar(user, src: user.avatar) # pass the attachment/uploader/URL directly
|
|
37
|
+
Avatar("acme-team") # a String subject is a deterministic seed
|
|
38
|
+
Avatar("https://.../p.png") # a URL-shaped subject is shown as the image
|
|
39
|
+
Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Param | Default | Notes |
|
|
43
|
+
|-----------|---------|-------|
|
|
44
|
+
| `subject` | `nil` | **Positional.** The identity the fallback is seeded from: a record (hashed to a PII-free seed) or a String. Also the default `alt`. A **URL-shaped** String (`http(s)://…` or `/…`) is treated as `src` instead, so `Avatar(photo_url)` shows the image. |
|
|
45
|
+
| `src:` | `nil` | The image. A **Symbol** names a method on the subject (`:avatar` → `subject.avatar`); otherwise an ActiveStorage attachment, [active_shrine](https://github.com/radioactive-labs/active_shrine)/Shrine uploader, or URL string. |
|
|
46
|
+
| `size:` | `:md` | Semantic `:xs 24 / :sm 32 / :md 40 / :lg 48 / :xl 64`, or a raw Integer (px). |
|
|
47
|
+
| `alt:` | derived | Defaults to the String subject, or the record's display name. |
|
|
48
|
+
| `class:` | — | Merged over the default `rounded-full` classes. |
|
|
49
|
+
|
|
50
|
+
### How the source resolves
|
|
51
|
+
|
|
52
|
+
`src` is resolved in this order, so the same component works across attachment libraries:
|
|
53
|
+
|
|
54
|
+
- **ActiveStorage** attachment → `helpers.url_for` (the Rails-routable redirect path)
|
|
55
|
+
- **active_shrine** / Shrine `UploadedFile` / CarrierWave (anything responding to `#url`) → `value.url`
|
|
56
|
+
- **URL string** (`"https://…"` or `"/…"`) → used as-is
|
|
57
|
+
|
|
58
|
+
When `src` is absent or unattached, a Navii avatar is rendered from the subject; with no subject either, a generic user icon is shown.
|
|
59
|
+
|
|
60
|
+
::: tip Symbol `src` is a contract
|
|
61
|
+
`Avatar(user, src: :avatar)` calls `user.avatar` — the subject **must** respond to it (a `NoMethodError` is raised otherwise). Use a Symbol `src` only with a record subject, not a value that might be a plain string (e.g. a guest `current_user`).
|
|
62
|
+
:::
|
|
63
|
+
|
|
64
|
+
### Privacy
|
|
65
|
+
|
|
66
|
+
The value sent to Navii is **always a hash** of the subject's identity (`Digest::SHA256` of `"Class:id"` for a record, or of the string for a String subject). No model names, IDs, emails, or seed strings ever reach the external service, and the avatar stays deterministic (same subject → same avatar).
|
|
67
|
+
|
|
68
|
+
### Configuration
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# config/initializers/plutonium.rb
|
|
72
|
+
Plutonium.configure do |config|
|
|
73
|
+
config.navii_host_url = "https://api.navii.dev" # default; repoint to self-host/proxy
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The component appends Navii's `/avatar/:seed` route to this host.
|
|
78
|
+
|
|
26
79
|
## Writing custom Phlex components
|
|
27
80
|
|
|
28
81
|
```ruby
|
data/docs/reference/ui/forms.md
CHANGED
|
@@ -150,7 +150,7 @@ end
|
|
|
150
150
|
- Bare tag — just the input element. Use when you're laying out custom wrappers.
|
|
151
151
|
- `wrapped(class: "...")` — pass classes to the wrapper div.
|
|
152
152
|
|
|
153
|
-
## Association inputs (`secure_association_tag`)
|
|
153
|
+
## Association inputs (`secure_association_tag`) {#association-inputs}
|
|
154
154
|
|
|
155
155
|
Association inputs render with two affordances out of the box:
|
|
156
156
|
|
data/docs/reference/ui/pages.md
CHANGED
|
@@ -133,17 +133,18 @@ See [Forms › Association inputs](./forms#association-inputs).
|
|
|
133
133
|
|
|
134
134
|
## Modals & slideovers
|
|
135
135
|
|
|
136
|
-
The framework's `:new` / `:edit` actions render inline inside a modal. Choose the chrome per-resource via the definition:
|
|
136
|
+
The framework's `:new` / `:edit` actions and any interactive action render inline inside a modal. Choose the chrome (and optional width) per-resource via the definition — interactive actions inherit the same default:
|
|
137
137
|
|
|
138
138
|
```ruby
|
|
139
139
|
class PostDefinition < ResourceDefinition
|
|
140
|
-
modal :slideover
|
|
141
|
-
# modal :centered
|
|
142
|
-
# modal
|
|
140
|
+
modal :slideover # default — slide-in panel from the right
|
|
141
|
+
# modal :centered # centered dialog
|
|
142
|
+
# modal :centered, size: :lg # centered, wider container
|
|
143
|
+
# modal false # full standalone pages (no modal)
|
|
143
144
|
end
|
|
144
145
|
```
|
|
145
146
|
|
|
146
|
-
|
|
147
|
+
`size:` accepts `:sm`, `:md` (default), `:lg`, `:xl`, `:auto` (hugs content width), or `:full`. Per-action `modal:` / `size:` on an interactive action overrides the definition's default. See [Resource › Actions](/reference/resource/actions#action-options).
|
|
147
148
|
|
|
148
149
|
## Tabs on the show page
|
|
149
150
|
|
data/docs/reference/ui/tables.md
CHANGED
|
@@ -8,8 +8,9 @@ The index page's table rendering. Override the `Table` nested class in your defi
|
|
|
8
8
|
class PostDefinition < ResourceDefinition
|
|
9
9
|
class Table < Table
|
|
10
10
|
def view_template
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
render_toolbar # search + view toggle + filter buttons
|
|
12
|
+
render_scopes_pills # scope chips (if any scopes defined)
|
|
13
|
+
render_filter_pills # active-filter chips
|
|
13
14
|
|
|
14
15
|
if collection.empty?
|
|
15
16
|
render_empty_card
|
|
@@ -20,6 +21,7 @@ class PostDefinition < ResourceDefinition
|
|
|
20
21
|
end
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
render_bulk_actions_toolbar
|
|
23
25
|
render_footer
|
|
24
26
|
end
|
|
25
27
|
end
|
|
@@ -30,8 +32,10 @@ end
|
|
|
30
32
|
|
|
31
33
|
| Method | Purpose |
|
|
32
34
|
|---|---|
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
+
| `render_toolbar` | Search input + view toggle + filter button |
|
|
36
|
+
| `render_scopes_pills` | Quick-filter scope chips (only renders if scopes defined) |
|
|
37
|
+
| `render_filter_pills` | Active-filter chips |
|
|
38
|
+
| `render_bulk_actions_toolbar` | Bulk action bar (only renders when rows selected) |
|
|
35
39
|
| `render_table` | Default table rendering |
|
|
36
40
|
| `render_empty_card` | Empty state |
|
|
37
41
|
| `render_footer` | Pagination |
|