plutonium 0.59.0 → 0.60.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-auth/SKILL.md +7 -1
- data/CHANGELOG.md +6 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/reference/auth/accounts.md +7 -0
- data/docs/reference/resource/definition.md +129 -0
- data/docs/reference/ui/forms.md +51 -21
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +917 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
- data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +237 -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/rodauth/admin_generator.rb +5 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
- data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
- data/lib/generators/pu/rodauth/views_generator.rb +1 -1
- data/lib/plutonium/definition/base.rb +1 -0
- data/lib/plutonium/definition/form_layout.rb +144 -0
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +17 -7
- data/lib/plutonium/ui/form/components/section.rb +58 -0
- data/lib/plutonium/ui/form/resource.rb +85 -7
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/slim_select.css +11 -2
- metadata +8 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"planPath": "docs/superpowers/plans/2026-06-14-form-sectioning.md",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"id": 1,
|
|
6
|
+
"subject": "Task 1: FormLayout DSL module + registry",
|
|
7
|
+
"status": "completed",
|
|
8
|
+
"description": "Add form_layout/section/ungrouped DSL + ordered inheritable registry + validations; include in Definition::Base and Interaction::Base.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/definition/form_layout.rb\", \"lib/plutonium/definition/base.rb\", \"lib/plutonium/interaction/base.rb\", \"test/plutonium/definition/form_layout_test.rb\", \"test/plutonium/interaction/form_layout_test.rb\"], \"verifyCommand\": \"bin/rails test test/plutonium/definition/form_layout_test.rb test/plutonium/interaction/form_layout_test.rb\", \"acceptanceCriteria\": [\"form_layout records sections in order\", \"section :ungrouped and duplicate ungrouped raise\", \"default label humanizes key\", \"subclasses inherit; redeclare replaces\", \"instance + interaction expose registry\"], \"requiresUserVerification\": false}\n```"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": 2,
|
|
12
|
+
"subject": "Task 2: Resolve field list into ordered sections",
|
|
13
|
+
"status": "completed",
|
|
14
|
+
"blockedBy": [1],
|
|
15
|
+
"description": "Add resolve_form_sections(resource_fields): assign fields (first-section-wins, order preserved), leftovers to ungrouped (default first / declared position), raise on unknown keys, keep empty sections.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/definition/form_layout.rb\", \"test/plutonium/definition/form_layout_resolution_test.rb\"], \"verifyCommand\": \"bin/rails test test/plutonium/definition/form_layout_resolution_test.rb\", \"acceptanceCriteria\": [\"nil without layout\", \"fields assigned in order; leftovers to ungrouped\", \"ungrouped default-first / explicit-position\", \"unknown field raises\", \"empty sections kept (first-section-wins)\"], \"requiresUserVerification\": false}\n```"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": 3,
|
|
19
|
+
"subject": "Task 3: Section chrome component + columns helper",
|
|
20
|
+
"status": "completed",
|
|
21
|
+
"blockedBy": [1],
|
|
22
|
+
"description": "Add Components::Section (heading/description, native <details> collapsible, grid by columns), yields field rendering to the form.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/ui/form/components/section.rb\", \"test/plutonium/ui/form/components/section_test.rb\"], \"verifyCommand\": \"bin/rails test test/plutonium/ui/form/components/section_test.rb\", \"acceptanceCriteria\": [\"heading + description render\", \"collapsible emits <details> with open/collapsed\", \"non-collapsible has no <details>\", \"grid_class applied and block content rendered\"], \"requiresUserVerification\": false}\n```"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": 4,
|
|
26
|
+
"subject": "Task 4: Render sections in resource forms + integration",
|
|
27
|
+
"status": "completed",
|
|
28
|
+
"blockedBy": [2, 3],
|
|
29
|
+
"description": "Form::Resource#render_fields groups via resolver + Section component, evaluates condition in form context, falls back to single grid. Register KitchenSink in admin + add form_layout to KitchenSinkDefinition; integration test.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/ui/form/resource.rb\", \"test/dummy/app/definitions/kitchen_sink_definition.rb\", \"test/dummy/packages/admin_portal/config/routes.rb\", \"test/integration/admin_portal/form_layout_rendering_test.rb\"], \"verifyCommand\": \"bin/rails test test/integration/admin_portal/form_layout_rendering_test.rb test/integration/admin_portal\", \"acceptanceCriteria\": [\"sections + headings render and group fields\", \"collapsible emits <details>\", \"no layout -> single grid unchanged\", \"falsey condition hides section\"], \"requiresUserVerification\": false}\n```"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": 5,
|
|
33
|
+
"subject": "Task 5: Interaction forms render sections",
|
|
34
|
+
"status": "completed",
|
|
35
|
+
"blockedBy": [4],
|
|
36
|
+
"description": "Confirm interaction forms render form_layout sections (shared render_fields path). Add form_layout to a dummy interaction exercised by an org_portal test; assert grouped render.\n\n```json:metadata\n{\"files\": [\"test/integration/org_portal/form_layout_interaction_test.rb\"], \"verifyCommand\": \"bin/rails test test/integration/org_portal/form_layout_interaction_test.rb\", \"acceptanceCriteria\": [\"interaction form renders section headings\", \"interaction attribute input names unchanged\"], \"requiresUserVerification\": false}\n```"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"lastUpdated": "2026-06-14T12:55:00Z"
|
|
40
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# Form Sectioning — Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-14
|
|
4
|
+
**Status:** Approved (pending spec review)
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
Plutonium forms render every field in a single responsive grid
|
|
9
|
+
(`Form::Resource#render_fields` walks a flat `resource_fields` list inside one
|
|
10
|
+
`fields_wrapper`). There is no way to group fields under headings ("Personal
|
|
11
|
+
details", "Address", …). We want a clean DSL for *sectioning* forms that works
|
|
12
|
+
for both resource definitions and interactions, without disturbing per-field
|
|
13
|
+
configuration.
|
|
14
|
+
|
|
15
|
+
## Constraints discovered in the codebase
|
|
16
|
+
|
|
17
|
+
- **Field config lives in the definition** (`input :x, ...` via
|
|
18
|
+
`DefineableProps`, stored as ordered hashes), but the **set and order of
|
|
19
|
+
fields actually rendered come from the policy** (`permitted_attributes_for(action)`,
|
|
20
|
+
via `submittable_attributes_for`). The form receives that flat list as
|
|
21
|
+
`resource_fields`.
|
|
22
|
+
- **Interactions reuse the resource form.** `Form::Interaction < Form::Resource`
|
|
23
|
+
and only swaps in `resource_fields` (from `interaction.attribute_names`) and
|
|
24
|
+
`resource_definition` (the interaction instance). So sectioning added to
|
|
25
|
+
`Form::Resource#render_fields` is inherited by interactions for free.
|
|
26
|
+
- The shared definition DSL is composed from `Plutonium::Definition::*` concern
|
|
27
|
+
modules. `StructuredInputs` is the precedent for a module mixed into **both**
|
|
28
|
+
`Definition::Base` and `Interaction::Base`.
|
|
29
|
+
|
|
30
|
+
Therefore: sectioning is a **presentation concern declared in the definition**
|
|
31
|
+
that must group a **policy-driven** field list — skipping fields the policy
|
|
32
|
+
filtered out and hiding sections that end up empty.
|
|
33
|
+
|
|
34
|
+
## DSL
|
|
35
|
+
|
|
36
|
+
A new shared module `Plutonium::Definition::FormLayout` provides `form_layout`.
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
form_layout do
|
|
40
|
+
section :identity, :name, :date_of_birth, :grade,
|
|
41
|
+
label: "Your identification", description: "Basic info"
|
|
42
|
+
|
|
43
|
+
section :address, :street, :city, :country,
|
|
44
|
+
collapsible: true, columns: 2,
|
|
45
|
+
condition: -> { object.requires_address? }
|
|
46
|
+
|
|
47
|
+
ungrouped label: "Other", collapsible: true
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### `section(key, *fields, **opts)`
|
|
52
|
+
|
|
53
|
+
- `key` — Symbol, the section's identity. Heading defaults to `key.to_s.humanize`.
|
|
54
|
+
`:ungrouped` is **reserved** for the macro below — `section :ungrouped, …`
|
|
55
|
+
raises `ArgumentError`.
|
|
56
|
+
- `*fields` — ordered field keys placed in this section.
|
|
57
|
+
- `**opts`:
|
|
58
|
+
- `label:` — overrides the humanized heading.
|
|
59
|
+
- `description:` — optional help line under the heading.
|
|
60
|
+
- `collapsible:` — Boolean (default `false`). Renders as a native
|
|
61
|
+
`<details>/<summary>` disclosure (no JS).
|
|
62
|
+
- `collapsed:` — Boolean (default `false`). Initial state when collapsible;
|
|
63
|
+
`false` ⇒ the `<details>` is `open`.
|
|
64
|
+
- `columns:` — Integer overriding the section's grid column count. Default
|
|
65
|
+
inherits the form's responsive grid.
|
|
66
|
+
- `condition:` — lambda evaluated in the **form instance context** (same
|
|
67
|
+
semantics as `input ..., condition:`; `object` and helpers available). A
|
|
68
|
+
falsey result renders nothing for the section.
|
|
69
|
+
|
|
70
|
+
### `ungrouped(**opts)`
|
|
71
|
+
|
|
72
|
+
- Configures the implicit bucket that auto-collects every permitted field not
|
|
73
|
+
claimed by a `section`. Takes **no field list**.
|
|
74
|
+
- Accepts the same options as `section` (`label:`, `description:`,
|
|
75
|
+
`collapsible:`, `collapsed:`, `columns:`, `condition:`).
|
|
76
|
+
- **Position:** where the macro is called sets where leftovers render. If the
|
|
77
|
+
macro is omitted, leftovers render **last** (appended after all declared
|
|
78
|
+
sections), with **no heading**. _(Amended — was "first"; see Amendments.)_
|
|
79
|
+
- Calling `ungrouped` more than once in a single `form_layout` raises
|
|
80
|
+
`ArgumentError`.
|
|
81
|
+
|
|
82
|
+
### Field configuration stays on `input`
|
|
83
|
+
|
|
84
|
+
`form_layout`/`section` only reference field **keys** and carry **section-level**
|
|
85
|
+
options. All per-field rendering config (`as:`, the field's own `label:`,
|
|
86
|
+
`choices:`, per-field `condition:`, `pre_submit:`, blocks) remains on the `input`
|
|
87
|
+
declaration. Layout never duplicates field config.
|
|
88
|
+
|
|
89
|
+
### Inheritance / override
|
|
90
|
+
|
|
91
|
+
- Re-declaring `form_layout` in a subclass **replaces** the parent layout as a
|
|
92
|
+
unit. Field-level `input` config continues to inherit normally.
|
|
93
|
+
- The section registry is duplicated to subclasses on `inherited` (mirrors
|
|
94
|
+
`StructuredInputs`).
|
|
95
|
+
|
|
96
|
+
### Backwards compatibility
|
|
97
|
+
|
|
98
|
+
No `form_layout` declared ⇒ the current single-grid behavior is used unchanged.
|
|
99
|
+
This is equivalent to one implicit, heading-less `ungrouped` region.
|
|
100
|
+
|
|
101
|
+
## Rendering
|
|
102
|
+
|
|
103
|
+
`Plutonium::Definition::FormLayout` exposes an ordered, frozen registry of
|
|
104
|
+
section specs (and the `ungrouped` spec + its position) on definition and
|
|
105
|
+
interaction instances.
|
|
106
|
+
|
|
107
|
+
`Form::Resource#render_fields` becomes:
|
|
108
|
+
|
|
109
|
+
1. **No layout** → existing path: one `fields_wrapper` over all `resource_fields`.
|
|
110
|
+
2. **Layout present**:
|
|
111
|
+
- Assign each `section` its `fields ∩ resource_fields`, preserving the
|
|
112
|
+
section's declared field order.
|
|
113
|
+
- The `ungrouped` bucket gets `resource_fields` minus all claimed fields,
|
|
114
|
+
preserving `resource_fields` order.
|
|
115
|
+
- Build the ordered render list: sections in declared order with the
|
|
116
|
+
`ungrouped` bucket inserted at its declared position (default: **last**).
|
|
117
|
+
- For each entry: evaluate `condition` (skip if falsey); render a Section
|
|
118
|
+
component with its renderable fields. Empty sections are **not**
|
|
119
|
+
special-cased — they render with defaults (see Edge cases).
|
|
120
|
+
|
|
121
|
+
Field rendering itself still goes through the existing `render_resource_field`,
|
|
122
|
+
so all input types/components are unaffected.
|
|
123
|
+
|
|
124
|
+
### Section component
|
|
125
|
+
|
|
126
|
+
New `Plutonium::UI::Form::Components::Section` (Phlex). Responsibilities:
|
|
127
|
+
|
|
128
|
+
- Wrapper + heading (`label`) + optional `description`.
|
|
129
|
+
- When `collapsible`, wrap in native `<details>`/`<summary>` (`open` unless
|
|
130
|
+
`collapsed`), styled with Tailwind/`--pu-*` tokens.
|
|
131
|
+
- A grid (`fields_wrapper`-style) whose column count comes from `columns:` when
|
|
132
|
+
given, else the existing responsive default
|
|
133
|
+
(`grid-cols-1 md:grid-cols-2 2xl:grid-cols-4`).
|
|
134
|
+
- Yields to render the section's fields via `render_resource_field`.
|
|
135
|
+
|
|
136
|
+
A small helper maps `columns:` → grid classes (e.g. `1 → "grid grid-cols-1 gap-6"`,
|
|
137
|
+
`2 → "grid grid-cols-1 md:grid-cols-2 gap-6"`). Theme entries added for section
|
|
138
|
+
heading/description/wrapper so they're themeable like the rest of the form.
|
|
139
|
+
|
|
140
|
+
## Edge cases
|
|
141
|
+
|
|
142
|
+
- **Field permitted but in no section** → falls into `ungrouped`.
|
|
143
|
+
- **Section references a policy-filtered field** → that individual field is
|
|
144
|
+
skipped (no input is rendered for an unpermitted attribute).
|
|
145
|
+
- **Empty section** (all fields filtered out, or none assigned) → **not
|
|
146
|
+
hidden**. It renders through the normal path with defaults (its default/declared
|
|
147
|
+
chrome). There is no automatic empty-hiding; to hide a section conditionally,
|
|
148
|
+
use `condition:`.
|
|
149
|
+
- **Unknown field key in a `section`** (not an attribute at all) → raise at
|
|
150
|
+
render with a clear message (catches typos), consistent with how the form
|
|
151
|
+
already errors on unknown fields.
|
|
152
|
+
- **`condition` falsey** → section renders nothing; its fields do **not** spill
|
|
153
|
+
into `ungrouped` (they remain owned by the suppressed section).
|
|
154
|
+
- **No leftovers** → `ungrouped` renders with defaults (with no fields and no
|
|
155
|
+
configured heading, that is simply nothing visible; a configured `label:`
|
|
156
|
+
still renders).
|
|
157
|
+
|
|
158
|
+
## Files
|
|
159
|
+
|
|
160
|
+
- **New** `lib/plutonium/definition/form_layout.rb` — DSL module: `form_layout`
|
|
161
|
+
block builder, ordered + inheritable section registry, `section` / `ungrouped`,
|
|
162
|
+
instance readers, validation (duplicate `ungrouped`, etc.).
|
|
163
|
+
- **Modify** `lib/plutonium/definition/base.rb` — `include FormLayout`.
|
|
164
|
+
- **Modify** `lib/plutonium/interaction/base.rb` — `include FormLayout`.
|
|
165
|
+
- **New** `lib/plutonium/ui/form/components/section.rb` — section component.
|
|
166
|
+
- **Modify** `lib/plutonium/ui/form/resource.rb` — `render_fields` grouping; columns→grid helper.
|
|
167
|
+
- **Modify** `lib/plutonium/ui/form/theme.rb` — section heading/description/wrapper tokens.
|
|
168
|
+
|
|
169
|
+
## Testing (RSpec)
|
|
170
|
+
|
|
171
|
+
- **DSL/registry:** sections recorded in order with options; `ungrouped` spec +
|
|
172
|
+
position; humanized default label; `label:` override; duplicate `ungrouped`
|
|
173
|
+
raises; `section :ungrouped` raises; inheritance duplicates registry;
|
|
174
|
+
re-declaring `form_layout` replaces.
|
|
175
|
+
- **Assignment:** fields land in the right section in declared order; leftovers
|
|
176
|
+
collect into `ungrouped`; `ungrouped` default position is last; explicit
|
|
177
|
+
position honored.
|
|
178
|
+
- **Filtering:** policy-filtered field is skipped; an empty section still renders
|
|
179
|
+
with defaults (is **not** hidden); unknown field key raises.
|
|
180
|
+
- **Conditions:** falsey `condition` hides the section and withholds its fields.
|
|
181
|
+
- **Rendering:** headings/descriptions present; collapsible emits
|
|
182
|
+
`<details>`/`<summary>` with correct `open`; `columns:` changes grid classes.
|
|
183
|
+
- **Interactions:** an interaction with `form_layout` sections renders grouped
|
|
184
|
+
via `Form::Interaction`.
|
|
185
|
+
- **Backwards-compat:** a definition with no `form_layout` renders the single
|
|
186
|
+
grid exactly as before.
|
|
187
|
+
|
|
188
|
+
## Out of scope (YAGNI)
|
|
189
|
+
|
|
190
|
+
- Applying sections to show/display pages or the index grid (forms only for now;
|
|
191
|
+
the same registry could later be reused by `Display::Resource`).
|
|
192
|
+
- Nested sections / tabs.
|
|
193
|
+
- Per-field layout hints (e.g. `field :notes, span: 2`) — Style 1 keeps fields
|
|
194
|
+
positional; this can be added later via the nested-block form if needed.
|
|
195
|
+
- Stimulus-driven animated collapse (native `<details>` chosen for leanness).
|
|
196
|
+
|
|
197
|
+
## Amendments (post-implementation)
|
|
198
|
+
|
|
199
|
+
Changes made after the original plan landed:
|
|
200
|
+
|
|
201
|
+
- **Implicit `ungrouped` placement: first → last.** When no `ungrouped` macro is
|
|
202
|
+
declared, leftover fields are now appended **after** all declared sections
|
|
203
|
+
(was: prepended). This matches the convention of the explicit macro ("the
|
|
204
|
+
rest" trails the sections you care about) and makes "omit it" equivalent to
|
|
205
|
+
"declare it last." To float leftovers above your sections, declare `ungrouped`
|
|
206
|
+
explicitly at the top.
|
|
207
|
+
|
|
208
|
+
- **`columns:` actually lays out in a grid.** Previously every field wrapper got
|
|
209
|
+
`col-span-full`, so a section's `columns: N` had no visible effect. Fields in a
|
|
210
|
+
multi-column section now flow into single grid cells. A field that declares its
|
|
211
|
+
own span (`input :x, wrapper: {class: "col-span-..."}`) **always wins** — in any
|
|
212
|
+
section — so authors can opt a field back to full width (or wider) inside a
|
|
213
|
+
multi-column section.
|
|
214
|
+
|
|
215
|
+
- **Dynamic section options.** Every section option except `columns:`
|
|
216
|
+
(`collapsed`, `collapsible`, `label`, `description`, plus the existing
|
|
217
|
+
`condition`) may be a **proc**, resolved at render time in the form instance
|
|
218
|
+
context — the same context as input/section `condition:` (so `object`,
|
|
219
|
+
`current_user`, `params`, helpers are available). The whole layout is resolved
|
|
220
|
+
once per render in `Form::Resource#resolve_form_layout` (visibility + option
|
|
221
|
+
evaluation in one pass); `render_form_section` is pure presentation. `columns:`
|
|
222
|
+
stays a validated literal (it feeds the grid class).
|
|
223
|
+
|
|
224
|
+
- **Interactions: verified + exercised.** `Form::Interaction < Form::Resource`
|
|
225
|
+
already inherited the layout path; this is now covered by a dummy interaction
|
|
226
|
+
(`ReconfigureKitchenSink`, a record action on `KitchenSink`) and an integration
|
|
227
|
+
test. In an interaction form `object` is the interaction instance and
|
|
228
|
+
`object.resource` is the record, so record-aware dynamic options work there too
|
|
229
|
+
(e.g. `collapsed: -> { object.resource.archived? }`).
|
|
230
|
+
|
|
231
|
+
- **Unrelated fix surfaced while driving the dummy in `development`:** the package
|
|
232
|
+
engine system (`Plutonium::Package::Engine`) called `Rails.application.initializers`
|
|
233
|
+
from a `before_configuration` hook, prematurely memoizing `Rails.application.railties`
|
|
234
|
+
and dropping package engines from the autoload paths when a second
|
|
235
|
+
`Rails::Application` (combustion, in dev) was instantiated before the packages
|
|
236
|
+
glob ran — surfacing as `uninitialized constant Blogging::Post`. The view-path
|
|
237
|
+
neutralization was moved to a real initializer (`before: :add_view_paths`).
|
|
@@ -95,13 +95,16 @@ module Pu
|
|
|
95
95
|
def create_invite_interaction
|
|
96
96
|
template "app/interactions/invite_admin_interaction.rb",
|
|
97
97
|
"app/interactions/#{normalized_name}/invite_interaction.rb"
|
|
98
|
+
template "app/interactions/resend_admin_interaction.rb",
|
|
99
|
+
"app/interactions/#{normalized_name}/resend_invite_interaction.rb"
|
|
98
100
|
|
|
99
101
|
inject_into_file "app/definitions/#{normalized_name}_definition.rb",
|
|
100
|
-
" action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n"
|
|
102
|
+
" action :invite, interaction: #{name.classify}::InviteInteraction, collection: true, category: :primary\n" \
|
|
103
|
+
" action :resend_invite, interaction: #{name.classify}::ResendInviteInteraction, record_action: true, category: :secondary\n",
|
|
101
104
|
after: /class #{name.classify}Definition < .+\n/
|
|
102
105
|
|
|
103
106
|
inject_into_file "app/policies/#{normalized_name}_policy.rb",
|
|
104
|
-
"def invite?\n true\n end\n\n ",
|
|
107
|
+
"def invite?\n true\n end\n\n def resend_invite?\n record.unverified?\n end\n\n ",
|
|
105
108
|
before: "# Core attributes"
|
|
106
109
|
end
|
|
107
110
|
|
|
@@ -20,7 +20,7 @@ module Pu
|
|
|
20
20
|
desc "Generate migrations for supported features\n\n" \
|
|
21
21
|
"Supported Features\n" \
|
|
22
22
|
"=========================================\n" \
|
|
23
|
-
"#{MIGRATION_CONFIG.keys.sort.
|
|
23
|
+
"#{MIGRATION_CONFIG.keys.sort.join "\n"}\n\n\n\n"
|
|
24
24
|
|
|
25
25
|
class_option :features, required: true, type: :array,
|
|
26
26
|
desc: "Rodauth features to create tables for (otp, sms_codes, single_session, account_expiration etc.)"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= name.classify %>::ResendInviteInteraction < Plutonium::Resource::Interaction
|
|
4
|
+
presents label: "Resend Invitation", icon: Phlex::TablerIcons::MailForward
|
|
5
|
+
|
|
6
|
+
attribute :resource
|
|
7
|
+
|
|
8
|
+
def execute
|
|
9
|
+
unless resource.unverified?
|
|
10
|
+
return failed("Can only resend invitations to unverified accounts")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RodauthApp.rodauth(:<%= normalized_name %>).verify_account_resend(login: resource.email)
|
|
14
|
+
succeed(resource).with_message("Invitation resent to #{resource.email}")
|
|
15
|
+
rescue ::Rodauth::InternalRequestError => e
|
|
16
|
+
failed(e.message)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -13,7 +13,7 @@ module Pu
|
|
|
13
13
|
desc "Generate views for selected features\n\n" \
|
|
14
14
|
"Supported Features\n" \
|
|
15
15
|
"=========================================\n" \
|
|
16
|
-
"#{VIEW_CONFIG.keys.sort.
|
|
16
|
+
"#{VIEW_CONFIG.keys.sort.join "\n"}\n\n\n\n"
|
|
17
17
|
|
|
18
18
|
argument :plugin_name, type: :string, optional: true,
|
|
19
19
|
desc: "[CONFIG] Name of the configured rodauth app. Leave blank to use the primary account."
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# Declarative form sectioning. Mixed into both resource definitions and
|
|
6
|
+
# interactions (mirrors StructuredInputs). The layout references field KEYS
|
|
7
|
+
# only and carries section-level options; per-field config stays on `input`.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# form_layout do
|
|
11
|
+
# section :identity, :name, :email, label: "Your identification"
|
|
12
|
+
# section :address, :street, :city, collapsible: true, columns: 2,
|
|
13
|
+
# condition: -> { object.requires_address? }
|
|
14
|
+
# ungrouped label: "Other"
|
|
15
|
+
# end
|
|
16
|
+
module FormLayout
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
UNGROUPED_KEY = :ungrouped
|
|
20
|
+
|
|
21
|
+
# One declared section, or the implicit `ungrouped` bucket (empty `fields`).
|
|
22
|
+
Section = Struct.new(:key, :fields, :options) do
|
|
23
|
+
def ungrouped? = key == UNGROUPED_KEY
|
|
24
|
+
def label = options[:label] || key.to_s.humanize
|
|
25
|
+
def description = options[:description]
|
|
26
|
+
def collapsible? = !!options[:collapsible]
|
|
27
|
+
def collapsed? = !!options[:collapsed]
|
|
28
|
+
def columns = options[:columns]
|
|
29
|
+
def condition = options[:condition]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A section paired with the concrete fields it will render (after policy
|
|
33
|
+
# filtering). Produced by #resolve_form_sections (a later task).
|
|
34
|
+
ResolvedSection = Struct.new(:section, :fields)
|
|
35
|
+
|
|
36
|
+
# Collects section/ungrouped calls from a form_layout block in order.
|
|
37
|
+
class Builder
|
|
38
|
+
attr_reader :sections
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@sections = []
|
|
42
|
+
@ungrouped_seen = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def section(key, *fields, **options)
|
|
46
|
+
if key == UNGROUPED_KEY
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"`section :#{UNGROUPED_KEY}` is reserved — use the `ungrouped` macro"
|
|
49
|
+
end
|
|
50
|
+
validate_columns!(options)
|
|
51
|
+
@sections << Section.new(key:, fields: fields.freeze, options: options.freeze)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ungrouped(**options)
|
|
55
|
+
raise ArgumentError, "`ungrouped` may only be declared once" if @ungrouped_seen
|
|
56
|
+
@ungrouped_seen = true
|
|
57
|
+
validate_columns!(options)
|
|
58
|
+
@sections << Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: options.freeze)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_columns!(options)
|
|
64
|
+
return unless options.key?(:columns)
|
|
65
|
+
value = options[:columns]
|
|
66
|
+
unless Integer === value && value > 0
|
|
67
|
+
raise ArgumentError,
|
|
68
|
+
"form_layout :columns must be a positive Integer, got #{value.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class_methods do
|
|
74
|
+
# Declare the form layout. Re-declaring replaces it as a unit.
|
|
75
|
+
def form_layout(&block)
|
|
76
|
+
raise ArgumentError, "`form_layout` requires a block" unless block
|
|
77
|
+
builder = Builder.new
|
|
78
|
+
builder.instance_exec(&block)
|
|
79
|
+
@defined_form_layout = builder.sections.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Ordered Array<Section>, or nil when no layout was declared.
|
|
83
|
+
def defined_form_layout
|
|
84
|
+
@defined_form_layout
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inherited(subclass)
|
|
88
|
+
super
|
|
89
|
+
subclass.instance_variable_set(:@defined_form_layout, defined_form_layout&.dup)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Instance access — the form render path holds a definition/interaction
|
|
94
|
+
# instance (mirrors the defineable_prop convention).
|
|
95
|
+
def defined_form_layout
|
|
96
|
+
self.class.defined_form_layout
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Resolve the policy-filtered field list into ordered ResolvedSections.
|
|
100
|
+
# Returns nil when no layout is declared (caller falls back to one grid).
|
|
101
|
+
def resolve_form_sections(resource_fields)
|
|
102
|
+
layout = defined_form_layout
|
|
103
|
+
return nil unless layout
|
|
104
|
+
|
|
105
|
+
resource_fields = resource_fields.map(&:to_sym)
|
|
106
|
+
known = resource_fields.to_set
|
|
107
|
+
|
|
108
|
+
# First-section-wins assignment: map each field to the first section key.
|
|
109
|
+
owner = {}
|
|
110
|
+
layout.each do |section|
|
|
111
|
+
next if section.ungrouped?
|
|
112
|
+
section.fields.each do |f|
|
|
113
|
+
unless known.include?(f)
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"form_layout section :#{section.key} references unknown field :#{f}"
|
|
116
|
+
end
|
|
117
|
+
owner[f] ||= section.key
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
leftovers = resource_fields.reject { |f| owner.key?(f) }
|
|
121
|
+
|
|
122
|
+
resolved = layout.map do |section|
|
|
123
|
+
fields =
|
|
124
|
+
if section.ungrouped?
|
|
125
|
+
leftovers
|
|
126
|
+
else
|
|
127
|
+
section.fields.select { |f| owner[f] == section.key }
|
|
128
|
+
end
|
|
129
|
+
ResolvedSection.new(section:, fields:)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
unless layout.any?(&:ungrouped?)
|
|
133
|
+
implicit = ResolvedSection.new(
|
|
134
|
+
section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
|
|
135
|
+
fields: leftovers
|
|
136
|
+
)
|
|
137
|
+
resolved.push(implicit)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
resolved
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -26,6 +26,7 @@ module Plutonium
|
|
|
26
26
|
include Plutonium::Definition::ConfigAttr
|
|
27
27
|
include Plutonium::Definition::Presentable
|
|
28
28
|
include Plutonium::Definition::StructuredInputs
|
|
29
|
+
include Plutonium::Definition::FormLayout
|
|
29
30
|
|
|
30
31
|
# On interactions, declaring a structured input also declares the backing
|
|
31
32
|
# ActiveModel attribute so the value survives `attributes=` and appears in
|
|
@@ -4,14 +4,24 @@ module Plutonium
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
included do
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
# Prevent this package's app/views from being appended to the global
|
|
8
|
+
# ActionController/ActionMailer view lookup — Plutonium resolves package
|
|
9
|
+
# views at the controller level (see Plutonium::Core::Controllers::Bootable,
|
|
10
|
+
# which reads current_engine.paths["app/views"]). We neutralize the
|
|
11
|
+
# engine's built-in `add_view_paths` initializer rather than clearing
|
|
12
|
+
# config.paths["app/views"], which that controller-level resolver needs.
|
|
13
|
+
#
|
|
14
|
+
# This MUST run as a real initializer (before :add_view_paths), NOT in
|
|
15
|
+
# before_configuration: that hook can fire before sibling package engines
|
|
16
|
+
# are loaded (it does in development, where :before_configuration has
|
|
17
|
+
# already run by the time config/packages.rb loads). Touching
|
|
18
|
+
# Rails.application.initializers there forces Rails.application.railties
|
|
19
|
+
# to memoize early — with only the packages loaded so far — permanently
|
|
20
|
+
# dropping the rest from the autoload paths (e.g. `uninitialized constant
|
|
21
|
+
# Blogging::Post`). By initializer-run time, railties is fully populated.
|
|
22
|
+
initializer :plutonium_neutralize_add_view_paths, before: :add_view_paths do
|
|
13
23
|
add_view_paths_initializer = Rails.application.initializers.find do |a|
|
|
14
|
-
a.context_class == self && a.name.to_s == "add_view_paths"
|
|
24
|
+
a.context_class == self.class && a.name.to_s == "add_view_paths"
|
|
15
25
|
end
|
|
16
26
|
add_view_paths_initializer&.instance_variable_set(:@block, ->(app) {})
|
|
17
27
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# Renders a form section's chrome (heading/description, optional native
|
|
8
|
+
# <details> collapsible, and a fields grid) and yields to a block that
|
|
9
|
+
# renders the section's fields (the form supplies render_resource_field).
|
|
10
|
+
class Section < Plutonium::UI::Component::Base
|
|
11
|
+
def initialize(resolved, grid_class:)
|
|
12
|
+
@section = resolved.section
|
|
13
|
+
@grid_class = grid_class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
SECTION_CLASS = "space-y-4 border-t border-[var(--pu-border-muted)] pt-6 first:border-t-0 first:pt-0"
|
|
17
|
+
HEADING_CLASS = "text-base font-semibold text-[var(--pu-text)]"
|
|
18
|
+
SUMMARY_CLASS = "#{HEADING_CLASS} cursor-pointer select-none"
|
|
19
|
+
DESCRIPTION_CLASS = "text-sm text-[var(--pu-text-muted)]"
|
|
20
|
+
|
|
21
|
+
def view_template(&fields_block)
|
|
22
|
+
if @section.collapsible?
|
|
23
|
+
details(open: !@section.collapsed?, class: SECTION_CLASS) do
|
|
24
|
+
summary(class: SUMMARY_CLASS) { heading_text }
|
|
25
|
+
describe
|
|
26
|
+
grid(&fields_block)
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
div(class: SECTION_CLASS) do
|
|
30
|
+
header_block
|
|
31
|
+
grid(&fields_block)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def header_block
|
|
39
|
+
return if @section.ungrouped? && @section.options[:label].nil?
|
|
40
|
+
h3(class: HEADING_CLASS) { heading_text }
|
|
41
|
+
describe
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def heading_text = @section.label
|
|
45
|
+
|
|
46
|
+
def describe
|
|
47
|
+
return unless @section.description
|
|
48
|
+
p(class: DESCRIPTION_CLASS) { @section.description }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def grid(&fields_block)
|
|
52
|
+
div(class: @grid_class, &fields_block)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|