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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,186 @@
1
+ # Docs Restructure & Compaction Design
2
+
3
+ **Date:** 2026-05-13
4
+ **Status:** Draft — awaiting approval
5
+
6
+ ## Problem
7
+
8
+ Following the skill consolidation (19 → 8 skills, ~37% volume cut), the `docs/` site is misaligned in two ways:
9
+
10
+ 1. **Reference structure** mirrors the OLD skill structure (separate `model/`, `definition/`, `policy/`, `controller/`, `interaction/`, `views/`, `assets/`, `portal/`, `generators/`). It should mirror the new 7 functional areas.
11
+ 2. **Concept/task split is muddled.** Some "guides" are really concept explanations (e.g. `guides/authorization.md` overlaps `reference/policy/`). Some concepts have NO reference home (auth, tenancy, testing — they live in `guides/` only).
12
+
13
+ ## Goals
14
+
15
+ **Primary goal: quality.** Volume reduction is incidental.
16
+
17
+ 1. Restructure `reference/` to mirror the 7 functional areas from the skill consolidation — so readers can predict where to look.
18
+ 2. Establish a clean role split: **guides = task recipes ("how do I X")**, **reference = concept lookup ("what does X do")**. Some duplication is fine when framed as different entry points; tables of options live in ONE place.
19
+ 3. Improve every page on these axes:
20
+ - **Right place** — concepts in reference, recipes in guides.
21
+ - **Right structure** — 🚨 callouts at top for "you'll regret this" rules; option/decision tables for scannability; decision rules over generic exhortations.
22
+ - **Right content** — keep WHY explanations that help reason about edge cases; cut marketing copy and empty "best practices" exhortations; verify technical accuracy as we go (the skill work caught real bugs — same energy).
23
+ 4. Light pass on the tutorial — preserve narrative flow; improve clarity where prose is unclear.
24
+
25
+ ## Non-Goals
26
+
27
+ - Restructuring `getting-started/` navigation (the tutorial arc stays).
28
+ - Changing VitePress theme, search provider, or build pipeline.
29
+ - Adding new content beyond reorganizing what exists.
30
+
31
+ ## Current state
32
+
33
+ 40 markdown files, ~13,222 lines.
34
+
35
+ | Area | Files | Notes |
36
+ |---|---|---|
37
+ | `getting-started/` | 11 | Index + installation + 8-step tutorial. Narrative learning arc. |
38
+ | `guides/` | 14 | Task-oriented but inconsistent — some concept-heavy. |
39
+ | `reference/` | 16 | Concept-by-concept, mirrors the OLD skill structure. |
40
+
41
+ ## Target reference structure (mirrors 7 skill areas)
42
+
43
+ ```
44
+ reference/
45
+ ├── index.md ← rewritten overview, links to the 7 areas
46
+ ├── app/
47
+ │ ├── index.md ← installation, configuration
48
+ │ ├── packages.md ← feature + portal packages
49
+ │ ├── portals.md ← portal engines, mounting, route registration
50
+ │ └── generators.md ← full generator catalog
51
+ ├── resource/
52
+ │ ├── index.md ← overview, the 4 layers
53
+ │ ├── model.md ← `Plutonium::Resource::Record`, has_cents, SGID, routing (merges current model/)
54
+ │ ├── definition.md ← field/input/display/column, page chrome (merges current definition/index + fields)
55
+ │ ├── query.md ← search, filters, scopes, sort
56
+ │ └── actions.md ← custom + bulk actions
57
+ ├── behavior/
58
+ │ ├── index.md ← overview, the controller/policy/interaction trio
59
+ │ ├── controllers.md ← hooks, key methods, presentation
60
+ │ ├── policies.md ← actions, permitted attributes, associations, relation_scope
61
+ │ └── interactions.md ← structure, outcomes, chaining, URL generation
62
+ ├── ui/
63
+ │ ├── index.md ← overview
64
+ │ ├── pages.md ← IndexPage/ShowPage/NewPage/EditPage, hooks
65
+ │ ├── forms.md ← field builder, layouts, theming, association inputs (current views/forms.md)
66
+ │ ├── displays.md ← Display class, custom rendering
67
+ │ ├── tables.md ← Table class, customization
68
+ │ ├── components.md ← component kit, custom Phlex components
69
+ │ ├── layouts.md ← shell, eject, ResourceLayout
70
+ │ └── assets.md ← Tailwind config, Stimulus, design tokens, .pu-* classes (current assets/)
71
+ ├── auth/ ← NEW (currently only in guides/)
72
+ │ ├── index.md ← Rodauth overview
73
+ │ ├── accounts.md ← basic, admin, SaaS account types
74
+ │ └── profile.md ← profile resource, SecuritySection
75
+ ├── tenancy/ ← NEW (currently spread across guides/)
76
+ │ ├── index.md ← overview, three pieces (portal/policy/model)
77
+ │ ├── entity-scoping.md ← associated_with, three model shapes
78
+ │ ├── nested-resources.md ← parent/child routes, scoping
79
+ │ └── invites.md ← invitation system
80
+ └── testing/ ← NEW (currently only in guides/)
81
+ ├── index.md
82
+ ├── crud.md
83
+ ├── policy.md
84
+ ├── nested.md
85
+ ├── portal-access.md
86
+ └── auth-helpers.md
87
+ ```
88
+
89
+ ### Content migrations
90
+
91
+ | Current file | Goes to |
92
+ |---|---|
93
+ | `reference/model/index.md` + `features.md` | `reference/resource/model.md` |
94
+ | `reference/definition/index.md` + `fields.md` | `reference/resource/definition.md` |
95
+ | `reference/definition/query.md` | `reference/resource/query.md` |
96
+ | `reference/definition/actions.md` | `reference/resource/actions.md` |
97
+ | `reference/controller/index.md` | `reference/behavior/controllers.md` |
98
+ | `reference/policy/index.md` | `reference/behavior/policies.md` |
99
+ | `reference/interaction/index.md` | `reference/behavior/interactions.md` |
100
+ | `reference/views/index.md` | split → `pages.md`, `displays.md`, `tables.md`, `components.md`, `layouts.md` |
101
+ | `reference/views/forms.md` | `reference/ui/forms.md` |
102
+ | `reference/assets/index.md` | `reference/ui/assets.md` |
103
+ | `reference/portal/index.md` | `reference/app/portals.md` |
104
+ | `reference/generators/index.md` | `reference/app/generators.md` |
105
+ | `getting-started/installation.md` | concept part → `reference/app/index.md`; task part stays |
106
+ | `guides/authentication.md` | concept part → `reference/auth/index.md` + `accounts.md`; recipe stays |
107
+ | `guides/user-profile.md` | concept part → `reference/auth/profile.md`; recipe stays |
108
+ | `guides/multi-tenancy.md` | concept part → `reference/tenancy/entity-scoping.md`; recipe stays |
109
+ | `guides/nested-resources.md` | concept part → `reference/tenancy/nested-resources.md`; recipe stays |
110
+ | `guides/user-invites.md` | concept part → `reference/tenancy/invites.md`; recipe stays |
111
+ | `guides/testing.md` | concept part → `reference/testing/*`; recipe stays |
112
+ | `guides/creating-packages.md` | concept part → `reference/app/packages.md`; recipe stays |
113
+
114
+ ## Guides restructure
115
+
116
+ Each guide becomes a clean **task recipe**:
117
+
118
+ - Single goal stated at the top ("Add authentication to your app")
119
+ - Numbered step-by-step
120
+ - Each step links to the relevant reference page for "why" and "what else"
121
+ - No exhaustive option tables (those live in reference)
122
+ - ~50-150 lines each, down from ~300-600
123
+
124
+ Keep the 14 guides at their current paths so external links don't break.
125
+
126
+ ## Editing principles (quality-first)
127
+
128
+ Not "cut everything"; cut what doesn't earn its keep. Specifically:
129
+
130
+ **Cut:**
131
+ - Marketing copy ("Plutonium is awesome because…").
132
+ - Empty "best practices" exhortations ("write clean code", "test your code").
133
+ - Content duplicated across pages — one canonical home + cross-link.
134
+ - Verbose prose where a 10-line snippet shows the same thing.
135
+ - Rails-101 explanations that don't set up a Plutonium twist.
136
+
137
+ **Keep — and add more of:**
138
+ - **WHY explanations** that help readers reason about edge cases.
139
+ - **Non-obvious gotchas** — the dangerous-default stuff that bites people.
140
+ - **Decision rules** ("use X when you need Y") over generic exhortations.
141
+ - **Option / field / DSL tables** — readers scan them, they don't read prose.
142
+ - **Inline code examples** that work — copy-pasteable, no `...` stand-ins.
143
+ - **🚨 callouts at top of each page** for the "you'll regret this" rules.
144
+
145
+ **Verify as we go.** The skill work caught real bugs (auto-detection rules, association input behavior, action visibility flags, `views` DSL naming). Same energy here — when prose claims X, check the source.
146
+
147
+ ## Tutorial compaction pass
148
+
149
+ Same cuts as above, but preserve:
150
+
151
+ - Step structure (1-8 stays)
152
+ - Narrative flow (one step builds on the next)
153
+ - Frequent "expected output" / "verify" callouts
154
+ - Screenshots and visuals (keep all references)
155
+
156
+ Target: 10-20% volume reduction without losing pedagogical value.
157
+
158
+ ## VitePress sidebar rewrite
159
+
160
+ `.vitepress/config.ts` sidebar rewritten for the new structure. Three sidebars:
161
+
162
+ - `/getting-started/` — unchanged
163
+ - `/guides/` — same 14 entries, reorganized into the 7 functional groups
164
+ - `/reference/` — new 7-area structure
165
+
166
+ ## Rollout
167
+
168
+ 1. **Pilot one reference area** — pick `reference/resource/` (largest, most-read). Build it from scratch using the merged skills as the template. Get user feedback on shape.
169
+ 2. **Build out the other 6 areas** — one at a time or in parallel, your call.
170
+ 3. **Move concept content** from guides into reference. Hollow guides become recipes.
171
+ 4. **Tutorial compaction pass** — minimal, last.
172
+ 5. **Rewrite VitePress sidebar** — once all paths exist.
173
+ 6. **Delete old reference directories** — `model/`, `definition/`, `policy/`, etc. — in one sweep after everything's in place.
174
+ 7. **Build the site locally** — verify no dead links, search index regenerates cleanly.
175
+
176
+ ## Risks
177
+
178
+ - **External links break.** GitHub PRs, blog posts, Stack Overflow answers may link to `reference/model/`, `reference/definition/actions`, etc. Mitigation: VitePress supports redirects, OR keep stub pages that redirect via meta refresh. Cheap to add.
179
+ - **Guides ↔ reference duplication drifts.** If a recipe and its reference page describe the same option differently, readers get confused. Mitigation: linting (no duplicate DSL/option tables across guide + reference).
180
+ - **Volume isn't the metric.** Some pages will get longer (currently underdeveloped topics: tenancy, testing, profile). Some will get shorter. The win is navigability and clarity, not line count.
181
+
182
+ ## Open questions
183
+
184
+ - Add `.vitepress` redirect configuration for old reference paths? (Recommended: yes, low cost.)
185
+ - Should `reference/app/generators.md` be the full catalog or a per-area split? (Recommended: full catalog — it's reference, scannability matters.)
186
+ - Are there guides that should be **deleted entirely** because they're 100% concept overlap with reference? (Decide after pilot.)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.49.0)
4
+ plutonium (0.50.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.49.0)
4
+ plutonium (0.50.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.49.1)
4
+ plutonium (0.50.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -9,14 +9,10 @@ module Pu
9
9
 
10
10
  desc "Update Plutonium gem and npm package to the latest version"
11
11
 
12
- SHELL_PIN_THRESHOLD = "0.49.1"
13
-
14
12
  def start
15
- @previous_gem_version = installed_gem_version
16
13
  update_gem
17
14
  update_npm_package
18
15
  sync_skills_if_present
19
- pin_shell_to_classic
20
16
  rescue => e
21
17
  exception "#{self.class} failed:", e
22
18
  end
@@ -30,22 +26,6 @@ module Pu
30
26
  Rails::Generators.invoke("pu:skills:sync", [], destination_root: Rails.root)
31
27
  end
32
28
 
33
- # Pin the shell config to :classic on apps upgrading from a version
34
- # that predates the shell change so the upgrade is invisible. Newer
35
- # apps installed after the shell change get :modern from the install
36
- # template and shouldn't be flipped.
37
- def pin_shell_to_classic
38
- return unless @previous_gem_version
39
- return unless SemanticRange.satisfies?(@previous_gem_version, "<=#{SHELL_PIN_THRESHOLD}")
40
-
41
- initializer = Rails.root.join("config", "initializers", "plutonium.rb")
42
- return unless File.file?(initializer)
43
- return if File.read(initializer).match?(/^\s*config\.shell\s*=/)
44
-
45
- say_status :update, "Pinning Plutonium shell to :classic...", :green
46
- configure_plutonium "config.shell = :classic"
47
- end
48
-
49
29
  def update_gem
50
30
  say_status :update, "Updating plutonium gem...", :green
51
31
  run "bundle update plutonium"
@@ -70,6 +70,7 @@ module Pu
70
70
  end
71
71
 
72
72
  def create_package
73
+ return if File.exist?(File.join(destination_root, "packages/invites"))
73
74
  generate "pu:pkg:package", "invites"
74
75
  end
75
76
 
@@ -33,7 +33,7 @@ module Plutonium
33
33
  include Scoping
34
34
  include Search
35
35
  include NestedInputs
36
- include Views
36
+ include IndexViews
37
37
  include Metadata
38
38
 
39
39
  class IndexPage < Plutonium::UI::Page::Index; end
@@ -7,17 +7,15 @@ module Plutonium
7
7
  #
8
8
  # @example Enable both views, default to Grid
9
9
  # class UserDefinition < Plutonium::Resource::Definition
10
- # views :table, :grid
11
- # default_view :grid
12
- #
13
10
  # grid_fields(
14
11
  # image: :avatar,
15
12
  # header: :name,
16
13
  # subheader: :email,
17
14
  # meta: [:role, :status]
18
15
  # )
16
+ # default_index_view :grid
19
17
  # end
20
- module Views
18
+ module IndexViews
21
19
  extend ActiveSupport::Concern
22
20
 
23
21
  KNOWN_VIEWS = %i[table grid].freeze
@@ -25,8 +23,8 @@ module Plutonium
25
23
  GRID_LAYOUTS = %i[compact media].freeze
26
24
 
27
25
  included do
28
- class_attribute :defined_views, default: [:table], instance_accessor: false
29
- class_attribute :defined_default_view, default: nil, instance_accessor: false
26
+ class_attribute :defined_index_views, default: [:table], instance_accessor: false
27
+ class_attribute :defined_default_index_view, default: nil, instance_accessor: false
30
28
  class_attribute :defined_grid_fields, default: {}, instance_accessor: false
31
29
  class_attribute :defined_grid_layout, default: :compact, instance_accessor: false
32
30
  class_attribute :defined_grid_columns, default: nil, instance_accessor: false
@@ -34,37 +32,40 @@ module Plutonium
34
32
 
35
33
  class_methods do
36
34
  # Declares the index views this resource supports.
35
+ # Usually unnecessary — declaring `grid_fields` auto-enables :grid
36
+ # alongside the default :table. Use `index_views` only to disable
37
+ # one (e.g. `index_views :grid` to drop the table view).
37
38
  # @param list [Array<Symbol>] one or more of {KNOWN_VIEWS}
38
- def views(*list)
39
+ def index_views(*list)
39
40
  list = list.flatten.map(&:to_sym)
40
41
  invalid = list - KNOWN_VIEWS
41
- raise ArgumentError, "Unknown views: #{invalid.inspect}. Valid: #{KNOWN_VIEWS}" if invalid.any?
42
- self.defined_views = list.empty? ? [:table] : list
42
+ raise ArgumentError, "Unknown index_views: #{invalid.inspect}. Valid: #{KNOWN_VIEWS}" if invalid.any?
43
+ self.defined_index_views = list.empty? ? [:table] : list
43
44
  end
44
45
 
45
- # Declares the default index view. Must be one of {.views}.
46
+ # Declares the default index view. Must be one of {.index_views}.
46
47
  # Falls back to the first declared view if unset.
47
- def default_view(name = nil)
48
+ def default_index_view(name = nil)
48
49
  if name.nil?
49
- defined_default_view || defined_views.first
50
+ defined_default_index_view || defined_index_views.first
50
51
  else
51
52
  name = name.to_sym
52
- unless defined_views.include?(name)
53
- raise ArgumentError, "default_view #{name.inspect} not in views #{defined_views.inspect}"
53
+ unless defined_index_views.include?(name)
54
+ raise ArgumentError, "default_index_view #{name.inspect} not in index_views #{defined_index_views.inspect}"
54
55
  end
55
- self.defined_default_view = name
56
+ self.defined_default_index_view = name
56
57
  end
57
58
  end
58
59
 
59
60
  # Maps grid slots to fields. Each slot is optional. Implicitly
60
- # adds `:grid` to {.views} so a resource can opt into the Grid
61
- # view simply by declaring its slots.
61
+ # adds `:grid` to {.index_views} so a resource can opt into the
62
+ # Grid view simply by declaring its slots.
62
63
  # @param slots [Hash{Symbol => Symbol, Array<Symbol>}]
63
64
  def grid_fields(**slots)
64
65
  invalid = slots.keys - GRID_SLOTS
65
66
  raise ArgumentError, "Unknown grid slots: #{invalid.inspect}. Valid: #{GRID_SLOTS}" if invalid.any?
66
67
  self.defined_grid_fields = slots
67
- self.defined_views = defined_views + [:grid] unless defined_views.include?(:grid)
68
+ self.defined_index_views = defined_index_views + [:grid] unless defined_index_views.include?(:grid)
68
69
  end
69
70
 
70
71
  # Layout shape for grid cards. :compact (default) places the image
@@ -84,8 +85,8 @@ module Plutonium
84
85
  end
85
86
  end
86
87
 
87
- def defined_views = self.class.defined_views
88
- def default_view = self.class.default_view
88
+ def defined_index_views = self.class.defined_index_views
89
+ def default_index_view = self.class.default_index_view
89
90
  def defined_grid_fields = self.class.defined_grid_fields
90
91
  def defined_grid_layout = self.class.defined_grid_layout
91
92
  def defined_grid_columns = self.class.defined_grid_columns
@@ -5,6 +5,17 @@ module Plutonium
5
5
  request.headers["Turbo-Frame"]
6
6
  end
7
7
 
8
+ # True when the request is rendered inside any turbo frame.
9
+ def in_frame? = current_turbo_frame.present?
10
+
11
+ # True when the request is rendered inside either modal frame
12
+ # (primary or secondary).
13
+ def in_modal? = Plutonium::MODAL_FRAMES.include?(current_turbo_frame)
14
+
15
+ # True when the request is rendered inside the secondary (stacked)
16
+ # modal frame specifically.
17
+ def in_secondary_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_SECONDARY_FRAME
18
+
8
19
  def remote_modal_frame_tag(&)
9
20
  turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME, &)
10
21
  end
@@ -9,6 +9,20 @@ module Plutonium
9
9
  end
10
10
  end
11
11
 
12
+ # Closes the <dialog> inside the targeted frame and empties the
13
+ # frame. Used to dismiss a stacked modal without affecting the
14
+ # rest of the page.
15
+ def turbo_stream_close_frame(frame_id)
16
+ turbo_stream_action_tag :close_frame, target: frame_id
17
+ end
18
+
19
+ # Reloads the targeted frame from its current src. Used to refresh
20
+ # the primary modal after a secondary-modal action mutates data
21
+ # the primary depends on.
22
+ def turbo_stream_reload_frame(frame_id)
23
+ turbo_stream_action_tag :reload_frame, target: frame_id
24
+ end
25
+
12
26
  private
13
27
 
14
28
  def turbo_stream_redirect_same_page?(url)
@@ -15,6 +15,7 @@ module Plutonium
15
15
  include Plutonium::Resource::Controllers::Queryable
16
16
  include Plutonium::Resource::Controllers::CrudActions
17
17
  include Plutonium::Resource::Controllers::InteractiveActions
18
+ include Plutonium::Resource::Controllers::Typeahead
18
19
 
19
20
  included do
20
21
  after_action { response.headers.merge!(@pagy.headers_hash) if @pagy }
@@ -58,7 +58,7 @@ module Plutonium
58
58
  elsif resource_record!.save
59
59
  format.turbo_stream do
60
60
  flash.notice = "#{resource_class.model_name.human} was successfully created."
61
- render turbo_stream: helpers.turbo_stream_redirect(redirect_url_after_submit)
61
+ render turbo_stream: stacked_modal_create_streams
62
62
  end
63
63
  format.html do
64
64
  redirect_to redirect_url_after_submit,
@@ -164,6 +164,24 @@ module Plutonium
164
164
 
165
165
  private
166
166
 
167
+ # When the create came in through the secondary (stacked) modal
168
+ # frame — i.e. the user clicked "+" next to an association field
169
+ # while the parent form was already in a modal — we don't want to
170
+ # navigate anywhere. Close the secondary dialog and reload the
171
+ # primary modal frame so the just-created record appears in the
172
+ # association select. Outside that case fall back to the normal
173
+ # post-submit redirect.
174
+ def stacked_modal_create_streams
175
+ if helpers.in_secondary_modal?
176
+ [
177
+ helpers.turbo_stream_close_frame(Plutonium::REMOTE_MODAL_SECONDARY_FRAME),
178
+ helpers.turbo_stream_reload_frame(Plutonium::REMOTE_MODAL_FRAME)
179
+ ]
180
+ else
181
+ helpers.turbo_stream_redirect(redirect_url_after_submit)
182
+ end
183
+ end
184
+
167
185
  def redirect_url_after_submit
168
186
  if (return_to = url_from(params[:return_to]))
169
187
  return return_to
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Resource
5
+ module Controllers
6
+ # Backend dispatch for typeahead/autocomplete queries against
7
+ # resource form inputs and index filter inputs. Auto-mounted on
8
+ # every Plutonium resource via the `interactive_resource_actions`
9
+ # routing concern (see Plutonium::Routing::MapperExtensions).
10
+ #
11
+ # The controller resolves what to query directly from the input
12
+ # definition + the model's association reflection — no widget
13
+ # indirection. Two source kinds are supported:
14
+ #
15
+ # 1. Static `choices: [...]` — case-insensitive substring filter.
16
+ # 2. Association — either `association_class:` set on the input,
17
+ # or inferred from `resource_class.reflect_on_association(name)`.
18
+ #
19
+ # Association queries route through the associated resource's
20
+ # `policy.relation_scope` so users only see records they can read.
21
+ module Typeahead
22
+ extend ActiveSupport::Concern
23
+
24
+ TYPEAHEAD_LIMIT = 50
25
+
26
+ # Priority list tried when the input doesn't tell us which
27
+ # column carries its label. Aligns with what `to_label` usually
28
+ # wraps. Used only as a last resort.
29
+ FALLBACK_SEARCH_COLUMNS = %w[name title label slug display_name email].freeze
30
+
31
+ # Returns the column to LIKE against when no `search` block is
32
+ # defined. Used by both the server (to build the WHERE clause)
33
+ # and the input component (to decide whether to attach the
34
+ # typeahead URL).
35
+ #
36
+ # Resolution order:
37
+ # 1. The input's `label_method` if it names a real column (so
38
+ # `input :user, label_method: :email` just works).
39
+ # 2. The first match from FALLBACK_SEARCH_COLUMNS.
40
+ # 3. nil — no usable column, server returns unfiltered.
41
+ #
42
+ # The fallback is fine for moderate tables but uses a leading-
43
+ # wildcard LIKE which can't be served by a b-tree index. For
44
+ # large tables, declare a `search` block that uses a trigram or
45
+ # full-text index instead.
46
+ def self.searchable_column_for(klass, label_method: nil)
47
+ cols = klass.column_names
48
+ if label_method && cols.include?(label_method.to_s)
49
+ return label_method.to_s
50
+ end
51
+ FALLBACK_SEARCH_COLUMNS.find { |c| cols.include?(c) }
52
+ end
53
+
54
+ # Escapes the SQL LIKE wildcards `%` and `_` (plus the escape
55
+ # char itself) so a user searching for "100%" doesn't match
56
+ # everything. The literal `!` is used as the ESCAPE character —
57
+ # unambiguous across sqlite/postgres/mysql, no backslash-quoting
58
+ # surprises.
59
+ LIKE_ESCAPE_CHAR = "!"
60
+ def self.escape_like(value)
61
+ value.to_s.gsub(/[!%_]/) { |c| "#{LIKE_ESCAPE_CHAR}#{c}" }
62
+ end
63
+
64
+ included do
65
+ before_action :authorize_typeahead!, only: %i[typeahead_input typeahead_filter]
66
+ # Read-only JSON; row-level auth is enforced inline through
67
+ # authorized_resource_scope, so the after_action verifier is
68
+ # redundant.
69
+ skip_verify_current_authorized_scope only: %i[typeahead_input typeahead_filter]
70
+ end
71
+
72
+ # GET /<resource>/typeahead/input/:name?q=...
73
+ def typeahead_input
74
+ field_name = params[:name].to_sym
75
+ defn = current_definition.defined_inputs[field_name]
76
+ # Inputs are often inferred from the model (no explicit
77
+ # `input :foo` in the definition). Accept the request when the
78
+ # field name maps to a real association even without an entry.
79
+ unless defn || resource_class.reflect_on_association(field_name)
80
+ return head(:not_found)
81
+ end
82
+
83
+ render_typeahead_response(defn || {}, field_name)
84
+ end
85
+
86
+ # GET /<resource>/typeahead/filter/:name?q=...
87
+ def typeahead_filter
88
+ filter = current_query_object.filter_definitions[params[:name].to_sym]
89
+ return head(:not_found) unless filter
90
+
91
+ defn = filter.defined_inputs[:value]
92
+ return head(:not_found) unless defn
93
+
94
+ render_typeahead_response(defn, params[:name].to_sym)
95
+ end
96
+
97
+ private
98
+
99
+ def render_typeahead_response(defn, field_name)
100
+ options = defn[:options] || {}
101
+ query = params[:q].to_s
102
+ candidates = collect_typeahead_candidates(options, field_name, query)
103
+
104
+ if candidates.nil?
105
+ return render(json: {error: "input has no typeahead source"}, status: :bad_request)
106
+ end
107
+
108
+ has_more = candidates.length > TYPEAHEAD_LIMIT
109
+ results = candidates.first(TYPEAHEAD_LIMIT).map { |row| serialize_typeahead_row(row) }
110
+ render json: {results: results, has_more: has_more}
111
+ end
112
+
113
+ # Returns the candidate list, or nil if the input has neither
114
+ # static choices nor a resolvable association class.
115
+ def collect_typeahead_candidates(options, field_name, query)
116
+ if options[:choices]
117
+ filter_static_choices(options[:choices], query)
118
+ elsif (klass = typeahead_association_class(options, field_name))
119
+ filter_association(klass, query, options)
120
+ end
121
+ end
122
+
123
+ def typeahead_association_class(options, field_name)
124
+ options[:association_class] ||
125
+ resource_class.reflect_on_association(field_name)&.klass
126
+ end
127
+
128
+ def filter_static_choices(choices, query)
129
+ return choices if query.blank?
130
+ q = query.downcase
131
+ choices.select { |label, _| label.to_s.downcase.include?(q) }
132
+ end
133
+
134
+ # Routes through the associated resource's policy.relation_scope
135
+ # so typeahead never surfaces records the user can't read, then
136
+ # narrows via the associated resource definition's `search` block
137
+ # when present. Without a search block, fall back to a case-
138
+ # insensitive LIKE on the first column in FALLBACK_SEARCH_COLUMNS
139
+ # that exists on the model (so a resource with a `name` column
140
+ # gets useful typeahead without declaring `search`). If neither
141
+ # search block nor fallback column is available, the relation is
142
+ # returned unfiltered (capped).
143
+ def filter_association(klass, query, options)
144
+ relation = options[:skip_authorization] ? klass.all : authorized_resource_scope(klass)
145
+ if query.present?
146
+ if (search_block = associated_definition_search_block(klass))
147
+ relation = search_block.call(relation, query)
148
+ elsif (col = Typeahead.searchable_column_for(klass, label_method: options[:label_method]))
149
+ quoted = klass.connection.quote_column_name(col)
150
+ pattern = "%#{Typeahead.escape_like(query.downcase)}%"
151
+ relation = relation.where("LOWER(#{quoted}) LIKE ? ESCAPE '#{Typeahead::LIKE_ESCAPE_CHAR}'", pattern)
152
+ end
153
+ end
154
+ relation.limit(Typeahead::TYPEAHEAD_LIMIT + 1).to_a
155
+ end
156
+
157
+ # Resolves the associated resource's `search` block, if declared.
158
+ # Goes through `resource_definition` so portal/package namespacing
159
+ # is honored (same fallback chain as the rest of the controller).
160
+ def associated_definition_search_block(klass)
161
+ resource_definition(klass).class._search_definition
162
+ rescue NameError
163
+ nil
164
+ end
165
+
166
+ def serialize_typeahead_row(row)
167
+ if row.is_a?(Array)
168
+ {value: row[1].to_s, label: row[0].to_s}
169
+ else
170
+ {value: row.to_signed_global_id.to_s, label: row.to_label}
171
+ end
172
+ end
173
+
174
+ def authorize_typeahead!
175
+ authorize_current! resource_class, to: :typeahead?
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -179,6 +179,13 @@ module Plutonium
179
179
  index?
180
180
  end
181
181
 
182
+ # Checks if typeahead/autocomplete queries are permitted.
183
+ #
184
+ # @return [Boolean] Delegates to index?.
185
+ def typeahead?
186
+ index?
187
+ end
188
+
182
189
  # Core attributes
183
190
 
184
191
  # Returns the permitted attributes for the create action.