plutonium 0.58.1 → 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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-auth/SKILL.md +7 -1
  3. data/.claude/skills/plutonium-behavior/SKILL.md +4 -0
  4. data/.claude/skills/plutonium-resource/SKILL.md +49 -0
  5. data/CHANGELOG.md +16 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/docs/.vitepress/config.ts +1 -0
  8. data/docs/reference/auth/accounts.md +7 -0
  9. data/docs/reference/resource/actions.md +3 -0
  10. data/docs/reference/resource/definition.md +129 -0
  11. data/docs/reference/resource/export.md +94 -0
  12. data/docs/reference/ui/forms.md +51 -21
  13. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +917 -0
  14. data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
  15. data/docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md +306 -0
  16. data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +237 -0
  17. data/gemfiles/rails_7.gemfile.lock +3 -1
  18. data/gemfiles/rails_8.0.gemfile.lock +3 -1
  19. data/gemfiles/rails_8.1.gemfile.lock +3 -1
  20. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +3 -3
  21. data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
  22. data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
  23. data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
  24. data/lib/generators/pu/rodauth/views_generator.rb +1 -1
  25. data/lib/plutonium/definition/base.rb +4 -0
  26. data/lib/plutonium/definition/form_layout.rb +144 -0
  27. data/lib/plutonium/interaction/base.rb +1 -0
  28. data/lib/plutonium/package/engine.rb +17 -7
  29. data/lib/plutonium/query/filter.rb +4 -1
  30. data/lib/plutonium/query/filters/association.rb +1 -2
  31. data/lib/plutonium/resource/controller.rb +1 -0
  32. data/lib/plutonium/resource/controllers/export_csv.rb +162 -0
  33. data/lib/plutonium/resource/controllers/queryable.rb +1 -0
  34. data/lib/plutonium/resource/policy.rb +21 -0
  35. data/lib/plutonium/routing/mapper_extensions.rb +13 -0
  36. data/lib/plutonium/ui/export_button.rb +86 -0
  37. data/lib/plutonium/ui/form/components/section.rb +58 -0
  38. data/lib/plutonium/ui/form/resource.rb +85 -7
  39. data/lib/plutonium/ui/table/components/toolbar.rb +9 -2
  40. data/lib/plutonium/ui/table/resource.rb +18 -1
  41. data/lib/plutonium/version.rb +1 -1
  42. data/package.json +1 -1
  43. data/plutonium.gemspec +1 -0
  44. data/src/css/slim_select.css +11 -2
  45. metadata +26 -2
@@ -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
@@ -98,11 +98,81 @@ module Plutonium
98
98
  end
99
99
 
100
100
  def render_fields
101
- fields_wrapper {
102
- resource_fields.each { |name|
103
- render_resource_field name
101
+ sections = resolve_form_layout
102
+ if sections.nil?
103
+ fields_wrapper {
104
+ resource_fields.each { |name| render_resource_field name }
104
105
  }
105
- }
106
+ else
107
+ sections.each { |rs| render_form_section(rs) }
108
+ end
109
+ end
110
+
111
+ # Resolve the whole form layout for THIS render, in one pass: drop
112
+ # condition-hidden sections and evaluate any proc-valued options, all in
113
+ # the form instance context (where `object`, `current_user`, `params` and
114
+ # helpers live — same context as input/section `condition:`). Returns nil
115
+ # when no form_layout is declared (caller falls back to a single grid).
116
+ def resolve_form_layout
117
+ sections = resource_definition.resolve_form_sections(resource_fields)
118
+ return nil if sections.nil?
119
+
120
+ sections.filter_map do |resolved|
121
+ section = resolved.section
122
+ condition = section.condition
123
+ next if condition && !instance_exec(&condition)
124
+
125
+ # `columns` stays a validated literal; everything else may be a proc.
126
+ options = section.options.to_h do |key, value|
127
+ [key, (key != :condition && value.is_a?(Proc)) ? instance_exec(&value) : value]
128
+ end
129
+ Plutonium::Definition::FormLayout::ResolvedSection.new(
130
+ section: Plutonium::Definition::FormLayout::Section.new(
131
+ key: section.key, fields: section.fields, options: options.freeze
132
+ ),
133
+ fields: resolved.fields
134
+ )
135
+ end
136
+ end
137
+
138
+ # Pure presentation — the section is already resolved (visible, options
139
+ # evaluated) by resolve_form_layout.
140
+ def render_form_section(resolved)
141
+ section = resolved.section
142
+ render Plutonium::UI::Form::Components::Section.new(
143
+ resolved, grid_class: section_grid_class(section.columns)
144
+ ) do
145
+ # Inside a multi-column section, let fields flow into the grid cells
146
+ # instead of forcing each to span the full row (see col-span default
147
+ # in render_simple_resource_field).
148
+ previous = @section_columns
149
+ @section_columns = section.columns
150
+ begin
151
+ resolved.fields.each { |name| render_resource_field name }
152
+ ensure
153
+ @section_columns = previous
154
+ end
155
+ end
156
+ end
157
+
158
+ # True while rendering fields inside a section that declared an explicit
159
+ # column count > 1. Such fields default to a single grid cell rather than
160
+ # `col-span-full`, so the section's grid actually lays out in columns.
161
+ def in_multi_column_section?
162
+ @section_columns.to_i > 1
163
+ end
164
+
165
+ # nil → the form's default responsive grid; an Integer overrides columns.
166
+ def section_grid_class(columns)
167
+ return themed(:fields_wrapper, nil) if columns.nil?
168
+
169
+ base = "grid gap-6 grid-flow-row-dense grid-cols-1"
170
+ case columns.to_i
171
+ when 1 then base
172
+ when 2 then "#{base} md:grid-cols-2"
173
+ when 3 then "#{base} md:grid-cols-2 lg:grid-cols-3"
174
+ else "#{base} md:grid-cols-2 2xl:grid-cols-#{columns.to_i}"
175
+ end
106
176
  end
107
177
 
108
178
  def render_actions
@@ -232,10 +302,18 @@ module Plutonium
232
302
  end
233
303
  else
234
304
  wrapper_options = input_options[:wrapper] || {}
305
+ # Only supply a default column span when the field hasn't declared its
306
+ # own (via `wrapper: {class: "col-span-..."}`). A field-level col-span
307
+ # ALWAYS wins — including inside a section with `columns:` — so authors
308
+ # can opt a single field back to full width in a multi-column section,
309
+ # or vice versa.
310
+ # TODO: remove the string check once theming supports class merges.
235
311
  if !wrapper_options[:class] || !wrapper_options[:class].include?("col-span")
236
- # temp hack to allow col span overrides
237
- # TODO: remove once we complete theming, which will support merges
238
- wrapper_options[:class] = tokens("col-span-full", wrapper_options[:class])
312
+ # In a multi-column section the field flows into a single grid cell
313
+ # (no col-span), so the declared `columns:` actually takes effect.
314
+ # Everywhere else fields span the full row.
315
+ default_span = in_multi_column_section? ? nil : "col-span-full"
316
+ wrapper_options[:class] = tokens(default_span, wrapper_options[:class])
239
317
  end
240
318
 
241
319
  render form.field(name, **field_options).wrapped(
@@ -8,7 +8,7 @@ module Plutonium
8
8
  # inline search, and column config / overflow icon buttons into a single
9
9
  # tight strip rendered above the table when shell == :modern.
10
10
  class Toolbar < Plutonium::UI::Component::Base
11
- def initialize(query:, search_url:, search_param: :q, search_value: nil, views: [:table], current_view: :table, view_cookie_name: nil, view_cookie_path: "/")
11
+ def initialize(query:, search_url:, search_param: :q, search_value: nil, views: [:table], current_view: :table, view_cookie_name: nil, view_cookie_path: "/", export: nil)
12
12
  @query = query
13
13
  @search_url = search_url
14
14
  @search_param = search_param
@@ -17,10 +17,11 @@ module Plutonium
17
17
  @current_view = current_view
18
18
  @view_cookie_name = view_cookie_name
19
19
  @view_cookie_path = view_cookie_path
20
+ @export = export
20
21
  end
21
22
 
22
23
  def render?
23
- @views.size > 1 || has_filters? || has_search?
24
+ @views.size > 1 || has_filters? || has_search? || @export.present?
24
25
  end
25
26
 
26
27
  def view_template
@@ -29,11 +30,17 @@ module Plutonium
29
30
  render switcher
30
31
  render_divider if switcher.render?
31
32
  render_filter_button
33
+ render_export_button if @export
32
34
  div(class: "flex-1")
33
35
  render_search if has_search?
34
36
  end
35
37
  end
36
38
 
39
+ # Export split button, rendered just after the Filter button.
40
+ def render_export_button
41
+ render Plutonium::UI::ExportButton.new(**@export)
42
+ end
43
+
37
44
  private
38
45
 
39
46
  def has_filters?
@@ -47,10 +47,27 @@ module Plutonium
47
47
  views: resource_definition.defined_index_views,
48
48
  current_view: :table,
49
49
  view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
50
- view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
50
+ view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request),
51
+ export: export_toolbar_config
51
52
  )
52
53
  end
53
54
 
55
+ # Export split-button config, or nil when the policy forbids export.
56
+ # The primary link carries the current query (selected scope + filters
57
+ # + search); "Export all" carries `?all=1`.
58
+ def export_toolbar_config
59
+ return nil unless current_policy.allowed_to?(:export_csv?)
60
+
61
+ {
62
+ scoped_url: resource_url_for(resource_class, action: :export_csv, **export_query_params),
63
+ all_url: resource_url_for(resource_class, action: :export_csv, all: 1)
64
+ }
65
+ end
66
+
67
+ def export_query_params
68
+ params[:q].present? ? {q: params[:q].to_unsafe_h} : {}
69
+ end
70
+
54
71
  def render_filter_pills
55
72
  TableFilterPills(query: current_query_object, total_count: pagy_instance&.count)
56
73
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.58.1"
2
+ VERSION = "0.60.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.58.1",
3
+ "version": "0.60.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
data/plutonium.gemspec CHANGED
@@ -51,6 +51,7 @@ Gem::Specification.new do |spec|
51
51
 
52
52
  spec.add_dependency "zeitwerk"
53
53
  spec.add_dependency "rails", ">= 7.2"
54
+ spec.add_dependency "csv" # CSV export; no longer a default gem on Ruby 3.4+
54
55
  spec.add_dependency "listen", "~> 3.8"
55
56
  spec.add_dependency "pagy", "~> 43.0"
56
57
  spec.add_dependency "rabl", "~> 0.17.0" # TODO: what to do with RABL
@@ -43,9 +43,11 @@
43
43
  border-radius: var(--pu-radius-md);
44
44
  color: var(--pu-text);
45
45
  }
46
+
46
47
  .ss-main:hover {
47
48
  border-color: var(--pu-border-strong);
48
49
  }
50
+
49
51
  .ss-main:focus,
50
52
  .ss-main[aria-expanded="true"] {
51
53
  border-color: var(--pu-input-focus-ring);
@@ -196,9 +198,11 @@
196
198
  font-size: inherit;
197
199
  line-height: inherit;
198
200
  }
201
+
199
202
  .ss-content .ss-search input::placeholder {
200
203
  color: var(--pu-input-placeholder);
201
204
  }
205
+
202
206
  .ss-content .ss-search input:focus {
203
207
  border-color: var(--pu-input-focus-ring);
204
208
  box-shadow: 0 0 0 3px theme(colors.primary.500 / 15%);
@@ -225,7 +229,7 @@
225
229
 
226
230
  /* List */
227
231
  .ss-content .ss-list {
228
- @apply flex-auto h-auto overflow-x-hidden overflow-y-auto;
232
+ @apply flex-auto h-auto overflow-x-hidden overflow-y-auto mb-2;
229
233
  }
230
234
 
231
235
  .ss-content .ss-list .ss-error {
@@ -345,10 +349,12 @@
345
349
  .ss-main.ss-invalid {
346
350
  @apply border-danger-500 bg-danger-50/50 text-danger-900;
347
351
  }
352
+
348
353
  .ss-main.ss-invalid:focus,
349
354
  .ss-main.ss-invalid[aria-expanded="true"] {
350
355
  box-shadow: 0 0 0 3px theme(colors.danger.500 / 15%);
351
356
  }
357
+
352
358
  .dark .ss-main.ss-invalid {
353
359
  @apply bg-danger-950/20 border-danger-500/70 text-danger-200;
354
360
  }
@@ -360,10 +366,12 @@
360
366
  .ss-main.ss-valid {
361
367
  @apply border-success-500 bg-success-50/50 text-success-900;
362
368
  }
369
+
363
370
  .ss-main.ss-valid:focus,
364
371
  .ss-main.ss-valid[aria-expanded="true"] {
365
372
  box-shadow: 0 0 0 3px theme(colors.success.500 / 15%);
366
373
  }
374
+
367
375
  .dark .ss-main.ss-valid {
368
376
  @apply bg-success-950/20 border-success-500/70 text-success-200;
369
377
  }
@@ -385,7 +393,8 @@
385
393
  width: 100% !important;
386
394
  border-radius: 0 !important;
387
395
  margin: 0 !important;
388
- pointer-events: none !important; /* Disabled by default */
396
+ pointer-events: none !important;
397
+ /* Disabled by default */
389
398
  }
390
399
 
391
400
  /* When active (dropdown is expanded), enable pointer events */
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.58.1
4
+ version: 0.60.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-10 00:00:00.000000000 Z
10
+ date: 2026-06-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '7.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: csv
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: listen
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -641,6 +655,7 @@ files:
641
655
  - docs/reference/index.md
642
656
  - docs/reference/resource/actions.md
643
657
  - docs/reference/resource/definition.md
658
+ - docs/reference/resource/export.md
644
659
  - docs/reference/resource/index.md
645
660
  - docs/reference/resource/model.md
646
661
  - docs/reference/resource/query.md
@@ -670,6 +685,8 @@ files:
670
685
  - docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json
671
686
  - docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md
672
687
  - docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json
688
+ - docs/superpowers/plans/2026-06-14-form-sectioning.md
689
+ - docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json
673
690
  - docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md
674
691
  - docs/superpowers/specs/2026-04-14-plutonium-testing-design.md
675
692
  - docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md
@@ -680,6 +697,8 @@ files:
680
697
  - docs/superpowers/specs/2026-05-29-avatar-component-design.md
681
698
  - docs/superpowers/specs/2026-06-01-structured-inputs-design.md
682
699
  - docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md
700
+ - docs/superpowers/specs/2026-06-12-export-csv-default-action-design.md
701
+ - docs/superpowers/specs/2026-06-14-form-sectioning-design.md
683
702
  - esbuild.config.js
684
703
  - exe/pug
685
704
  - gemfiles/rails_7.gemfile
@@ -888,6 +907,7 @@ files:
888
907
  - lib/generators/pu/rodauth/templates/app/controllers/plugin_controller.rb.tt
889
908
  - lib/generators/pu/rodauth/templates/app/controllers/rodauth_controller.rb.tt
890
909
  - lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt
910
+ - lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt
891
911
  - lib/generators/pu/rodauth/templates/app/mailers/account_mailer.rb.tt
892
912
  - lib/generators/pu/rodauth/templates/app/mailers/rodauth_mailer.rb.tt
893
913
  - lib/generators/pu/rodauth/templates/app/models/account.rb.tt
@@ -979,6 +999,7 @@ files:
979
999
  - lib/plutonium/definition/base.rb
980
1000
  - lib/plutonium/definition/config_attr.rb
981
1001
  - lib/plutonium/definition/defineable_props.rb
1002
+ - lib/plutonium/definition/form_layout.rb
982
1003
  - lib/plutonium/definition/index_views.rb
983
1004
  - lib/plutonium/definition/inheritable_config_attr.rb
984
1005
  - lib/plutonium/definition/metadata.rb
@@ -1045,6 +1066,7 @@ files:
1045
1066
  - lib/plutonium/resource/controllers/crud_actions.rb
1046
1067
  - lib/plutonium/resource/controllers/crud_actions/index_action.rb
1047
1068
  - lib/plutonium/resource/controllers/defineable.rb
1069
+ - lib/plutonium/resource/controllers/export_csv.rb
1048
1070
  - lib/plutonium/resource/controllers/interactive_actions.rb
1049
1071
  - lib/plutonium/resource/controllers/presentable.rb
1050
1072
  - lib/plutonium/resource/controllers/queryable.rb
@@ -1105,6 +1127,7 @@ files:
1105
1127
  - lib/plutonium/ui/dyna_frame/content.rb
1106
1128
  - lib/plutonium/ui/dyna_frame/host.rb
1107
1129
  - lib/plutonium/ui/empty_card.rb
1130
+ - lib/plutonium/ui/export_button.rb
1108
1131
  - lib/plutonium/ui/form/base.rb
1109
1132
  - lib/plutonium/ui/form/components/easymde.rb
1110
1133
  - lib/plutonium/ui/form/components/flatpickr.rb
@@ -1113,6 +1136,7 @@ files:
1113
1136
  - lib/plutonium/ui/form/components/json.rb
1114
1137
  - lib/plutonium/ui/form/components/key_value_store.rb
1115
1138
  - lib/plutonium/ui/form/components/resource_select.rb
1139
+ - lib/plutonium/ui/form/components/section.rb
1116
1140
  - lib/plutonium/ui/form/components/secure_association.rb
1117
1141
  - lib/plutonium/ui/form/components/secure_polymorphic_association.rb
1118
1142
  - lib/plutonium/ui/form/components/sticky_footer.rb