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,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.)
|
|
@@ -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"
|
|
@@ -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
|
|
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 :
|
|
29
|
-
class_attribute :
|
|
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
|
|
39
|
+
def index_views(*list)
|
|
39
40
|
list = list.flatten.map(&:to_sym)
|
|
40
41
|
invalid = list - KNOWN_VIEWS
|
|
41
|
-
raise ArgumentError, "Unknown
|
|
42
|
-
self.
|
|
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 {.
|
|
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
|
|
48
|
+
def default_index_view(name = nil)
|
|
48
49
|
if name.nil?
|
|
49
|
-
|
|
50
|
+
defined_default_index_view || defined_index_views.first
|
|
50
51
|
else
|
|
51
52
|
name = name.to_sym
|
|
52
|
-
unless
|
|
53
|
-
raise ArgumentError, "
|
|
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.
|
|
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 {.
|
|
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.
|
|
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
|
|
88
|
-
def
|
|
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:
|
|
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.
|