plutonium 0.50.0 → 0.51.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 +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -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/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# Pages
|
|
2
|
+
|
|
3
|
+
Each definition has nested page classes for index / show / new / edit / interactive-action. Override the ones you need.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Definition
|
|
9
|
+
├── IndexPage → renders Table
|
|
10
|
+
├── ShowPage → renders Display
|
|
11
|
+
├── NewPage → renders Form
|
|
12
|
+
├── EditPage → renders Form
|
|
13
|
+
└── InteractiveActionPage → renders Form
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Page classes
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
class PostDefinition < ResourceDefinition
|
|
20
|
+
class IndexPage < IndexPage; end
|
|
21
|
+
class ShowPage < ShowPage; end
|
|
22
|
+
class NewPage < NewPage; end
|
|
23
|
+
class EditPage < EditPage; end
|
|
24
|
+
class InteractiveActionPage < InteractiveActionPage; end
|
|
25
|
+
class Form < Form; end
|
|
26
|
+
class Table < Table; end
|
|
27
|
+
class Display < Display; end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Page titles, descriptions, breadcrumbs
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
class PostDefinition < ResourceDefinition
|
|
35
|
+
index_page_title "Blog Posts"
|
|
36
|
+
index_page_description "Manage all published articles"
|
|
37
|
+
show_page_title "Article Details"
|
|
38
|
+
show_page_title -> { current_record!.title } # dynamic
|
|
39
|
+
new_page_title "Create Post"
|
|
40
|
+
edit_page_title -> { "Edit: #{current_record!.title}" }
|
|
41
|
+
|
|
42
|
+
breadcrumbs true # global default
|
|
43
|
+
index_page_breadcrumbs false # per-page override
|
|
44
|
+
show_page_breadcrumbs true
|
|
45
|
+
new_page_breadcrumbs true
|
|
46
|
+
edit_page_breadcrumbs true
|
|
47
|
+
interactive_action_page_breadcrumbs true
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Page hooks (preferred over `view_template`)
|
|
52
|
+
|
|
53
|
+
Every page inherits these — use them instead of overriding `view_template` to preserve breadcrumbs, header, and DynaFrame behavior:
|
|
54
|
+
|
|
55
|
+
| Hook | Position |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `render_before_header` / `_after_header` | wraps the entire header section |
|
|
58
|
+
| `render_before_breadcrumbs` / `_after_breadcrumbs` | around the breadcrumb row |
|
|
59
|
+
| `render_before_page_header` / `_after_page_header` | around the title + actions block |
|
|
60
|
+
| `render_before_toolbar` / `_after_toolbar` | around the action toolbar |
|
|
61
|
+
| `render_before_content` / `_after_content` | around main content |
|
|
62
|
+
| `render_before_footer` / `_after_footer` | around footer/pagination |
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class ShowPage < ShowPage
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def page_title
|
|
71
|
+
"#{object.title} — #{object.author.name}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_before_content
|
|
75
|
+
div(class: "alert alert-info") do
|
|
76
|
+
plain "This post has #{object.comments.count} comments"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_after_content
|
|
81
|
+
render RelatedPostsComponent.new(post: object)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_toolbar
|
|
85
|
+
div(class: "flex gap-2") do
|
|
86
|
+
button(class: "pu-btn pu-btn-md pu-btn-secondary") { "Preview" }
|
|
87
|
+
button(class: "pu-btn pu-btn-md pu-btn-primary") { "Publish" }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Custom ERB views (full replacement)
|
|
94
|
+
|
|
95
|
+
For total control, drop the page class entirely with an ERB view at the controller path:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
app/views/posts/show.html.erb
|
|
99
|
+
packages/admin_portal/app/views/admin_portal/posts/show.html.erb
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The default view simply renders the page class:
|
|
103
|
+
|
|
104
|
+
```erb
|
|
105
|
+
<%= render current_definition.show_page_class.new %>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Mix — keep the default and add chrome around it:
|
|
109
|
+
|
|
110
|
+
```erb
|
|
111
|
+
<div class="announcement-banner">Special announcement</div>
|
|
112
|
+
<%= render current_definition.show_page_class.new %>
|
|
113
|
+
<div class="related"><%= render partial: "related" %></div>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Detecting render context
|
|
117
|
+
|
|
118
|
+
| Helper | True when |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `in_frame?` | Request targets a turbo-frame |
|
|
121
|
+
| `in_modal?` | Request renders inside a modal/slideover (primary or secondary) |
|
|
122
|
+
| `in_secondary_modal?` | Request renders inside the stacked secondary modal |
|
|
123
|
+
|
|
124
|
+
Use to pin action strips, omit nav chrome, or swap layouts.
|
|
125
|
+
|
|
126
|
+
### Stacked modals (secondary frame)
|
|
127
|
+
|
|
128
|
+
Association inputs include an inline `+` button. When the parent form is itself rendered in a modal, the `+` opens a **second stacked modal** in `Plutonium::REMOTE_MODAL_SECONDARY_FRAME` instead of replacing the primary modal. On successful create, the secondary closes and the primary frame reloads so the new record appears in the select — no developer wiring.
|
|
129
|
+
|
|
130
|
+
For custom flows: `helpers.turbo_stream_close_frame(frame_id)` and `helpers.turbo_stream_reload_frame(frame_id)` are available.
|
|
131
|
+
|
|
132
|
+
See [Forms › Association inputs](./forms#association-inputs).
|
|
133
|
+
|
|
134
|
+
## Modals & slideovers
|
|
135
|
+
|
|
136
|
+
The framework's `:new` / `:edit` actions render inline inside a modal. Choose the chrome per-resource via the definition:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
class PostDefinition < ResourceDefinition
|
|
140
|
+
modal :slideover # default — slide-in panel from the right
|
|
141
|
+
# modal :centered # centered dialog
|
|
142
|
+
# modal false # full standalone pages (no modal)
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Custom interactive actions render in their own dialog with their own per-action `modal:` option (`:centered` default, or `:slideover`). See [Resource › Actions](/reference/resource/actions#action-options).
|
|
147
|
+
|
|
148
|
+
## Tabs on the show page
|
|
149
|
+
|
|
150
|
+
Show pages with `permitted_associations` (see [Behavior › Policy](/reference/behavior/policies#association-permissions)) render a tablist: **Details** tab first, then one tab per association. The active tab is reflected in the URL hash (`#products`, `#refund-requests`) so the page deep-links and the active state survives reload / back navigation. Tab rows scroll horizontally on narrow viewports — they don't wrap.
|
|
151
|
+
|
|
152
|
+
## Portal-specific overrides
|
|
153
|
+
|
|
154
|
+
Each portal can override page classes independently. The portal definition inherits from the base definition, and its nested classes inherit from the base's nested classes:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
class AdminPortal::PostDefinition < ::PostDefinition
|
|
158
|
+
class ShowPage < ShowPage # inherits from ::PostDefinition::ShowPage
|
|
159
|
+
def render_after_content
|
|
160
|
+
super
|
|
161
|
+
render AdminOnlySection.new(post: object)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Available context
|
|
168
|
+
|
|
169
|
+
Inside any page / form / display / Phlex component, the same set of helpers is available — model accessors, definition/policy methods, URL helpers, `current_user`. For the full list, see [Behavior › Controllers › Key methods](/reference/behavior/controllers#key-methods) — pages inherit the same surface.
|
|
170
|
+
|
|
171
|
+
In Phlex components, Rails helpers are accessed via the `helpers` proxy:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class MyComponent < Plutonium::UI::Component::Base
|
|
175
|
+
def view_template
|
|
176
|
+
helpers.link_to(...)
|
|
177
|
+
helpers.number_to_currency(...)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Related
|
|
183
|
+
|
|
184
|
+
- [Forms](./forms) — Form class, field builder, themes
|
|
185
|
+
- [Displays](./displays) — show-page Display class
|
|
186
|
+
- [Tables](./tables) — index-page Table class
|
|
187
|
+
- [Components](./components) — built-in component kit, custom Phlex components, DynaFrame
|
|
188
|
+
- [Layouts](./layouts) — overall shell, eject, ResourceLayout
|
|
189
|
+
- [Resource › Definition](/reference/resource/definition) — page titles, breadcrumbs, modal mode, metadata panel
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Tables
|
|
2
|
+
|
|
3
|
+
The index page's table rendering. Override the `Table` nested class in your definition for custom layouts (e.g. card grids).
|
|
4
|
+
|
|
5
|
+
## Custom table template
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class PostDefinition < ResourceDefinition
|
|
9
|
+
class Table < Table
|
|
10
|
+
def view_template
|
|
11
|
+
render_search_bar
|
|
12
|
+
render_scopes_bar
|
|
13
|
+
|
|
14
|
+
if collection.empty?
|
|
15
|
+
render_empty_card
|
|
16
|
+
else
|
|
17
|
+
# Replace the table with a card grid
|
|
18
|
+
div(class: "grid grid-cols-3 gap-4") do
|
|
19
|
+
collection.each { |post| render PostCardComponent.new(post:) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
render_footer
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Methods
|
|
30
|
+
|
|
31
|
+
| Method | Purpose |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `render_search_bar` | Toolbar search input |
|
|
34
|
+
| `render_scopes_bar` | Quick-filter scope buttons |
|
|
35
|
+
| `render_table` | Default table rendering |
|
|
36
|
+
| `render_empty_card` | Empty state |
|
|
37
|
+
| `render_footer` | Pagination |
|
|
38
|
+
| `collection` | Paginated records |
|
|
39
|
+
| `resource_fields` | Column field names |
|
|
40
|
+
|
|
41
|
+
## Per-column customization
|
|
42
|
+
|
|
43
|
+
Prefer declaring column behavior in the **definition** rather than overriding the entire `Table`:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class PostDefinition < ResourceDefinition
|
|
47
|
+
column :title, align: :start # default
|
|
48
|
+
column :status, align: :center
|
|
49
|
+
column :amount, align: :end
|
|
50
|
+
|
|
51
|
+
# formatter — receives just the value
|
|
52
|
+
column :description, formatter: ->(value) { value&.truncate(30) }
|
|
53
|
+
column :price, formatter: ->(value) { "$%.2f" % value if value }
|
|
54
|
+
|
|
55
|
+
# block — receives the full record
|
|
56
|
+
column :full_name do |record|
|
|
57
|
+
"#{record.first_name} #{record.last_name}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
See [Resource › Definition › Column options](/reference/resource/definition#column-options).
|
|
63
|
+
|
|
64
|
+
## Grid view
|
|
65
|
+
|
|
66
|
+
For card-based layouts as a switchable alternative to the table, use the built-in Grid view — declare `grid_fields` in the definition:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class UserDefinition < ResourceDefinition
|
|
70
|
+
grid_fields(
|
|
71
|
+
image: :avatar,
|
|
72
|
+
header: :name,
|
|
73
|
+
subheader: :email,
|
|
74
|
+
body: :bio,
|
|
75
|
+
meta: [:role, :status],
|
|
76
|
+
footer: :last_seen_at
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
default_index_view :grid
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
See [Resource › Definition › Index views](/reference/resource/definition#index-views-table-grid). You only need a custom `Table` class when you want something neither Table nor Grid covers.
|
|
84
|
+
|
|
85
|
+
## Theming
|
|
86
|
+
|
|
87
|
+
Override the theme via a nested `Theme` class:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class PostDefinition < ResourceDefinition
|
|
91
|
+
class Table < Table
|
|
92
|
+
class Theme < Plutonium::UI::Table::Theme
|
|
93
|
+
def self.theme
|
|
94
|
+
super.merge(
|
|
95
|
+
wrapper: "pu-table-wrapper",
|
|
96
|
+
base: "pu-table",
|
|
97
|
+
header: "pu-table-header",
|
|
98
|
+
header_cell: "pu-table-header-cell",
|
|
99
|
+
body_row: "pu-table-body-row",
|
|
100
|
+
body_cell: "pu-table-body-cell"
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Theme keys
|
|
109
|
+
|
|
110
|
+
`wrapper`, `base`, `header`, `header_cell`, `body_row`, `body_cell`, `sort_icon`.
|
|
111
|
+
|
|
112
|
+
## Related
|
|
113
|
+
|
|
114
|
+
- [Pages](./pages) — `IndexPage` render hooks (a lighter alternative for top/bottom chrome)
|
|
115
|
+
- [Components](./components) — `PostCardComponent` and other reusable Phlex pieces
|
|
116
|
+
- [Resource › Definition](/reference/resource/definition) — column configuration, grid view
|
|
117
|
+
- [Resource › Query](/reference/resource/query) — search, filters, scopes, sort
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Typeahead Endpoint Design
|
|
2
|
+
|
|
3
|
+
**Status:** Approved (2026-05-09)
|
|
4
|
+
**Author:** stefan
|
|
5
|
+
**Scope:** New backend-driven typeahead/autocomplete primitive for resource form inputs and index filter inputs.
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Add an async typeahead endpoint to every Plutonium resource so association-backed selects (and any future typeahead-capable input) can fetch matching records from the server instead of materialising up to `DEFAULT_CHOICE_LIMIT` options into the page at render time. This unblocks association pickers over large tables (where the existing 100-row cap silently truncates) without forcing every input into a custom JS solution.
|
|
10
|
+
|
|
11
|
+
## Non-goals
|
|
12
|
+
|
|
13
|
+
- Pagination of typeahead results (we use a hard cap with an overflow indicator; pagination can be added later if a real need surfaces).
|
|
14
|
+
- Rich result rows (subtitle, icon, avatar). MVP returns minimal `{value, label}` per row; richer payloads are a separate iteration.
|
|
15
|
+
- Replacing the existing eager-list ResourceSelect; the eager path stays as the fallback / small-table mode.
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
Three layers, mirroring how `Plutonium::Resource::Controllers::InteractiveActions` is composed today.
|
|
20
|
+
|
|
21
|
+
### 1. Routing — `Plutonium::Routing::MapperExtensions`
|
|
22
|
+
|
|
23
|
+
Two routes are added to the existing `interactive_resource_actions` concern (auto-mounted on every Plutonium resource alongside `record_actions`, `bulk_actions`, etc.):
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
GET /<resource>/typeahead/input/:name?q=… → typeahead_input
|
|
27
|
+
GET /<resource>/typeahead/filter/:name?q=… → typeahead_filter
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Both collection-level. **No member variant** — authorization on the parent resource class is sufficient (see "Authorization" below).
|
|
31
|
+
|
|
32
|
+
### 2. Controller concern — `Plutonium::Resource::Controllers::Typeahead`
|
|
33
|
+
|
|
34
|
+
Two thin actions plus a single `before_action` for auth.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
module Plutonium::Resource::Controllers::Typeahead
|
|
38
|
+
extend ActiveSupport::Concern
|
|
39
|
+
|
|
40
|
+
included do
|
|
41
|
+
before_action :authorize_typeahead!, only: %i[typeahead_input typeahead_filter]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def typeahead_input
|
|
45
|
+
name = params[:name].to_sym
|
|
46
|
+
defn = current_definition.defined_inputs[name]
|
|
47
|
+
return head(:not_found) unless defn
|
|
48
|
+
|
|
49
|
+
render_typeahead_response(defn)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def typeahead_filter
|
|
53
|
+
name = params[:name].to_sym
|
|
54
|
+
filter = current_query_object.filter_definitions[name]
|
|
55
|
+
return head(:not_found) unless filter
|
|
56
|
+
|
|
57
|
+
defn = filter.defined_inputs[:value]
|
|
58
|
+
return head(:not_found) unless defn
|
|
59
|
+
|
|
60
|
+
render_typeahead_response(defn)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def render_typeahead_response(defn)
|
|
66
|
+
klass = lookup_input_class(defn)
|
|
67
|
+
return render(json: { error: "input is not typeahead-capable" }, status: :bad_request) unless klass < Plutonium::UI::Form::Components::Searchable
|
|
68
|
+
|
|
69
|
+
widget = klass.build_for_typeahead(defn[:options] || {})
|
|
70
|
+
results, has_more = widget.typeahead(
|
|
71
|
+
query: params[:q].to_s,
|
|
72
|
+
limit: TYPEAHEAD_LIMIT,
|
|
73
|
+
controller: self
|
|
74
|
+
)
|
|
75
|
+
render json: { results: results, has_more: has_more }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def authorize_typeahead!
|
|
79
|
+
authorize! resource_class, to: :typeahead?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Maps the input definition's :as symbol (e.g. :resource_select) to a
|
|
83
|
+
# component class. Backed by an explicit registry — only inputs that
|
|
84
|
+
# opted in by including Searchable register here, so anything not in
|
|
85
|
+
# the registry falls through to the 400 branch.
|
|
86
|
+
def lookup_input_class(defn)
|
|
87
|
+
Plutonium::UI::Form::Components::Searchable.registry[defn[:options]&.[](:as)&.to_sym]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`TYPEAHEAD_LIMIT` is a module-level constant (default `50`). Easy to tune.
|
|
93
|
+
|
|
94
|
+
### 3. Search behavior — `Plutonium::UI::Form::Components::Searchable`
|
|
95
|
+
|
|
96
|
+
A small mixin. Mixed into `ResourceSelect` (and into any future input that wants typeahead). Two-method public surface:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
module Plutonium::UI::Form::Components::Searchable
|
|
100
|
+
extend ActiveSupport::Concern
|
|
101
|
+
|
|
102
|
+
# Maps :as symbol -> component class. Each typeahead-capable widget
|
|
103
|
+
# populates this when it includes Searchable so the controller can
|
|
104
|
+
# dispatch by name without a brittle inflection convention.
|
|
105
|
+
def self.registry
|
|
106
|
+
@registry ||= {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class_methods do
|
|
110
|
+
# Subclasses call this to claim their :as symbol in the registry.
|
|
111
|
+
def typeahead_input_name(name)
|
|
112
|
+
Plutonium::UI::Form::Components::Searchable.registry[name.to_sym] = self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Allocates the widget and assigns just the ivars #typeahead needs.
|
|
116
|
+
# Bypasses Phlex's render-time build_attributes pipeline so we don't
|
|
117
|
+
# need a field/form context to run the search.
|
|
118
|
+
def build_for_typeahead(options)
|
|
119
|
+
allocate.tap { |w| w.send(:apply_typeahead_options, options) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns [results_array, has_more_bool]. results entries are { value:, label: }.
|
|
124
|
+
def typeahead(query:, limit:, controller:)
|
|
125
|
+
raw = collect_typeahead_candidates(query, controller: controller)
|
|
126
|
+
over = raw.length > limit
|
|
127
|
+
[raw.first(limit).map { |r| serialize_typeahead_row(r) }, over]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`ResourceSelect` implements `apply_typeahead_options`, `collect_typeahead_candidates`, and `serialize_typeahead_row`:
|
|
133
|
+
|
|
134
|
+
- `apply_typeahead_options(options)` reads `@association_class`, `@raw_choices`, `@choice_limit`, `@skip_authorization` from the input definition's options hash — the same keys the existing `build_attributes` consumes at render time.
|
|
135
|
+
- `collect_typeahead_candidates` branches:
|
|
136
|
+
- if `@raw_choices` (static list) — `@raw_choices.select { |label, _| label.to_s.downcase.include?(query.downcase) }`. No auth: choices are static, definition-author-controlled.
|
|
137
|
+
- elsif `@association_class` — runs the search through `controller.send(:authorized_resource_scope, @association_class)` so the associated resource's `policy.relation_scope` enforces row-level auth, then applies the associated resource definition's `search` block if present, else `LIKE` on the column backing `to_label` (or skips filtering when query is blank).
|
|
138
|
+
- `serialize_typeahead_row(row)` returns `{ value: row.to_signed_global_id.to_s, label: row.to_label }` for records, or `{ value: raw_value, label: raw_label }` for static choices.
|
|
139
|
+
|
|
140
|
+
The cap is **`limit + 1`** at the SQL level (`LIMIT 51` for a `limit: 50` request) so we can detect overflow without a separate `COUNT`.
|
|
141
|
+
|
|
142
|
+
## Authorization
|
|
143
|
+
|
|
144
|
+
Two gates, layered:
|
|
145
|
+
|
|
146
|
+
1. **Parent gate** — `policy.typeahead?` on the resource hosting the endpoint. Defaults to `index?` (collection-shaped — typeahead is "list/search records of this class", not "show one record"). Override per-resource if needed (e.g. `def typeahead? = create? || update?` to require write intent).
|
|
147
|
+
2. **Row gate** — when the input is association-backed, results are scoped through the *associated* resource's `policy.relation_scope` via the existing `authorized_resource_scope` helper. So a user can typeahead Authors only if they're allowed to read Authors, regardless of whether they can edit Posts.
|
|
148
|
+
|
|
149
|
+
Static `choices` lists bypass the row gate (they're not records, they're definition-author-controlled enumerations).
|
|
150
|
+
|
|
151
|
+
## Data flow
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
Browser (Stimulus controller)
|
|
155
|
+
fetch GET /widgets/typeahead/input/author?q=ali
|
|
156
|
+
↓
|
|
157
|
+
Typeahead#typeahead_input
|
|
158
|
+
authorize_typeahead! → policy.typeahead? on Widget [parent gate]
|
|
159
|
+
defn = current_definition.defined_inputs[:author]
|
|
160
|
+
widget = ResourceSelect.build_for_typeahead(defn[:options])
|
|
161
|
+
widget.typeahead(query: "ali", limit: 50, controller: self)
|
|
162
|
+
authorized_resource_scope(User).where("name LIKE ?", "%ali%").limit(51) [row gate]
|
|
163
|
+
serialize each → { value: sgid, label: to_label }
|
|
164
|
+
render json: { results: [...], has_more: false }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Components
|
|
168
|
+
|
|
169
|
+
| File | Responsibility |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `lib/plutonium/routing/mapper_extensions.rb` | Add 2 routes to the `interactive_resource_actions` concern. |
|
|
172
|
+
| `lib/plutonium/resource/controllers/typeahead.rb` | **New.** Controller concern with `typeahead_input`/`typeahead_filter` actions, auth, dispatch, JSON serialization. |
|
|
173
|
+
| `lib/plutonium/resource/controller.rb` | Include `Controllers::Typeahead`. |
|
|
174
|
+
| `lib/plutonium/resource/policy.rb` | Add `typeahead?` defaulting to `index?`. |
|
|
175
|
+
| `lib/plutonium/ui/form/components/searchable.rb` | **New.** `Searchable` mixin (class-level `build_for_typeahead`, instance-level `typeahead`). |
|
|
176
|
+
| `lib/plutonium/ui/form/components/resource_select.rb` | Include `Searchable`, call `typeahead_input_name :resource_select` to register. Implement `apply_typeahead_options`, `collect_typeahead_candidates`, `serialize_typeahead_row`. Wire Stimulus controller + remote URL data attrs into the rendered `<select>`. |
|
|
177
|
+
| `src/js/controllers/resource_select_controller.js` | **New.** Stimulus controller: debounced fetch, populates options on the underlying `<select>`, surfaces overflow hint, handles network errors. |
|
|
178
|
+
|
|
179
|
+
## Error handling
|
|
180
|
+
|
|
181
|
+
- Unknown input/filter name → `404 Not Found`.
|
|
182
|
+
- Input class registered but doesn't include `Searchable` → `400 Bad Request` with `{error: "input is not typeahead-capable"}`.
|
|
183
|
+
- Authorization failure → existing `ActionPolicy::Unauthorized` flow → `403`.
|
|
184
|
+
- Empty/blank `q` → return all candidates within the cap (so initial dropdown open shows something useful, mirroring the eager mode).
|
|
185
|
+
- Network/parse errors on the JS side → controller leaves the existing `<select>` options intact and shows a small "couldn't search" inline notice; user can retry.
|
|
186
|
+
|
|
187
|
+
## Testing
|
|
188
|
+
|
|
189
|
+
- **Unit — `Searchable#typeahead` (ResourceSelect):** static choices filter case-insensitively; association case routes through `authorized_resource_scope`; overflow detection (`limit+1` rows in DB → `has_more: true`); blank query returns top-N.
|
|
190
|
+
- **Controller — `Typeahead#typeahead_input` / `typeahead_filter`:** happy path renders correct JSON envelope; unknown name → 404; non-searchable input class → 400; auth denied → 403.
|
|
191
|
+
- **Integration:** full request through `admin_portal` hitting a registered resource, verifying SGID round-trip (the value in the response is accepted by ResourceSelect on form submit).
|
|
192
|
+
- **JS — Stimulus controller:** debounces input, handles `has_more`, handles network errors. Lightweight, behavior-focused.
|
|
193
|
+
|
|
194
|
+
## Migration & rollout
|
|
195
|
+
|
|
196
|
+
- Existing eager `ResourceSelect` keeps working — typeahead is opt-in per render via a flag on the input definition (e.g. `as: :resource_select, typeahead: true`). When unset, the component renders today's eager list. The Stimulus controller only attaches when `data-resource-select-typeahead-url-value` is present.
|
|
197
|
+
- Filter inputs default to typeahead when the underlying input class supports it (filters are the worst pain point for the 100-row cap).
|
|
198
|
+
|
|
199
|
+
## Open questions / deferred
|
|
200
|
+
|
|
201
|
+
- Server-driven sort order beyond what `relation_scope` returns (e.g. recency, fuzzy-rank). Out of scope for MVP.
|
|
202
|
+
- Multi-select typeahead UX (chips, paste-multiple). MVP supports `multiple: true` mechanically (the array of SGIDs round-trips fine), but the dropdown UX is single-select-shaped. Iteration.
|
|
203
|
+
- Caching/coalescing repeated queries client-side. Defer.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Skill Compaction & Consolidation Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-05-12
|
|
4
|
+
**Status:** Approved (pending implementation)
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
The `.claude/skills/` directory currently holds 19 Plutonium skills totaling ~7,846 lines. Several issues:
|
|
9
|
+
|
|
10
|
+
- Skills are too verbose for a stable-API framework. Plutonium rarely changes shape, so re-explaining concepts has little ROI.
|
|
11
|
+
- Several skills are read together for any non-trivial task (e.g. create-resource + model + definition). Loading them separately wastes context.
|
|
12
|
+
- Some skills duplicate content (Rails-isms, philosophy preambles, repeated DSL explanations).
|
|
13
|
+
|
|
14
|
+
Skills are written for **developers using the framework**, not for first-time Rails users. They should read like reference + decision rules, not tutorials.
|
|
15
|
+
|
|
16
|
+
## Goals
|
|
17
|
+
|
|
18
|
+
1. Reduce total skill volume by ~45% (target: ~4,150 lines from 7,846).
|
|
19
|
+
2. Merge skills that are almost always loaded together.
|
|
20
|
+
3. Keep skills self-contained with inline code examples (chosen over linking to `test/dummy`).
|
|
21
|
+
4. Preserve high-value reference material (option/DSL/field tables).
|
|
22
|
+
|
|
23
|
+
## Non-Goals
|
|
24
|
+
|
|
25
|
+
- Restructuring user-facing `docs/` site.
|
|
26
|
+
- Changing the framework API.
|
|
27
|
+
- Splitting examples into separate files outside the skill.
|
|
28
|
+
|
|
29
|
+
## Target Skill Map
|
|
30
|
+
|
|
31
|
+
From 19 skills to 8:
|
|
32
|
+
|
|
33
|
+
| New skill | Merges | Est. lines |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `plutonium` | (router, kept) | ~150 |
|
|
36
|
+
| `plutonium-app` | installation + portal + package | ~600 |
|
|
37
|
+
| `plutonium-resource` | create-resource + model + definition | ~800 |
|
|
38
|
+
| `plutonium-behavior` | controller + policy + interaction | ~700 |
|
|
39
|
+
| `plutonium-ui` | views + forms + assets | ~700 |
|
|
40
|
+
| `plutonium-auth` | (kept, compacted) | ~350 |
|
|
41
|
+
| `plutonium-tenancy` | entity-scoping + nested-resources + invites | ~600 |
|
|
42
|
+
| `plutonium-testing` | (kept, compacted) | ~250 |
|
|
43
|
+
|
|
44
|
+
**Total: ~4,150 lines.**
|
|
45
|
+
|
|
46
|
+
### Rationale per merge
|
|
47
|
+
|
|
48
|
+
- **plutonium-app** — installation, portal creation, and package creation are the setup arc. Always done together on a new app.
|
|
49
|
+
- **plutonium-resource** — model declarations, scaffold options, and definition DSL are the core "build a resource" workflow.
|
|
50
|
+
- **plutonium-behavior** — controllers, policies, and interactions form the request/authorization/business-logic layer.
|
|
51
|
+
- **plutonium-ui** — views, forms, and assets all touch presentation. Assets covers the toolchain backing both views and forms.
|
|
52
|
+
- **plutonium-tenancy** — entity-scoping is the core mechanic; nested-resources and invites are both consumers of that mechanic.
|
|
53
|
+
- **plutonium-auth** stays solo at the user's request (rodauth/profile is distinct enough from tenancy/invites).
|
|
54
|
+
- **plutonium-testing** stays solo (orthogonal concern, loaded only for test work).
|
|
55
|
+
|
|
56
|
+
## Compaction Rules
|
|
57
|
+
|
|
58
|
+
Applied to every skill during merge.
|
|
59
|
+
|
|
60
|
+
**Cut:**
|
|
61
|
+
- Rails/Ruby basics — assume reader knows Rails.
|
|
62
|
+
- Philosophy/motivation preambles.
|
|
63
|
+
- Duplicated content across merged skills (one canonical location per concept).
|
|
64
|
+
- Verbose prose where a 10-line snippet shows the same thing.
|
|
65
|
+
- Marketing copy ("Plutonium gives you...").
|
|
66
|
+
|
|
67
|
+
**Keep:**
|
|
68
|
+
- Decision rules ("use X when…, Y when…").
|
|
69
|
+
- Non-obvious gotchas and constraints.
|
|
70
|
+
- Short canonical inline snippets.
|
|
71
|
+
- **Option/field/DSL tables** — high-value reference, kept verbatim.
|
|
72
|
+
- Cross-references to other skills via `[[plutonium-resource]]` style links.
|
|
73
|
+
|
|
74
|
+
## Format per merged skill
|
|
75
|
+
|
|
76
|
+
1. **Header paragraph** — what this covers + when to load.
|
|
77
|
+
2. **Sub-sections per merged topic** — each with: decision rules → minimal inline example → gotchas → tables (where applicable).
|
|
78
|
+
3. **Cross-references** at bottom.
|
|
79
|
+
|
|
80
|
+
## Rollout
|
|
81
|
+
|
|
82
|
+
1. **Pilot:** `plutonium-resource` first (largest, hardest — biggest signal on whether the template works).
|
|
83
|
+
2. **Review pilot together** — adjust template if needed.
|
|
84
|
+
3. **Apply pattern** to the remaining merges. One PR per merged skill OR all-in-one (TBD with user).
|
|
85
|
+
4. **Update `plutonium` router skill** last — it references the new names.
|
|
86
|
+
5. **Delete old skill directories** only after the new one lands.
|
|
87
|
+
|
|
88
|
+
Skills require a gem release to take effect for users (per `CLAUDE.md`), so this ships as a single release regardless of how PRs are split.
|
|
89
|
+
|
|
90
|
+
## Risks
|
|
91
|
+
|
|
92
|
+
- **Loss of granularity for context loading** — a single merged skill loads more tokens even when only one sub-topic is needed. Mitigated by aggressive compaction (loose budget but still much smaller than today's biggest individual skills).
|
|
93
|
+
- **Cross-references breaking** — the `plutonium` router skill and any external references must update at the same time as the merge.
|
|
94
|
+
- **Drift from `docs/`** — the user-facing docs site may still reference old skill structure; out of scope for this spec but worth noting.
|
|
95
|
+
|
|
96
|
+
## Open Questions
|
|
97
|
+
|
|
98
|
+
- Should the merge land as one PR or eight? (Deferred to rollout time.)
|
|
99
|
+
- Are there external references to the old skill names (other repos, marketplace listings) that need updating?
|