plutonium 0.49.1 → 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 +37 -0
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1323 -1184
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +50 -49
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +4 -4
- data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
- data/app/views/resource/_resource_grid.html.erb +1 -0
- data/config/brakeman.ignore +25 -2
- 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/plans/2026-05-07-ui-layout-overhaul.md +841 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
- data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -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/install/templates/config/initializers/plutonium.rb +1 -0
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
- data/lib/plutonium/action/base.rb +44 -1
- data/lib/plutonium/action/interactive.rb +1 -1
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/definition/actions.rb +3 -0
- data/lib/plutonium/definition/base.rb +8 -0
- data/lib/plutonium/definition/index_views.rb +95 -0
- data/lib/plutonium/definition/metadata.rb +40 -0
- data/lib/plutonium/helpers/turbo_helper.rb +12 -1
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/interaction/response/redirect.rb +1 -1
- data/lib/plutonium/query/base.rb +8 -0
- data/lib/plutonium/query/filters/association.rb +30 -8
- data/lib/plutonium/query/filters/boolean.rb +5 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/presentable.rb +11 -2
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/definition.rb +42 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/resource/query_object.rb +64 -6
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/testing/resource_definition.rb +2 -2
- data/lib/plutonium/ui/action_button.rb +4 -2
- data/lib/plutonium/ui/component/kit.rb +12 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/display/base.rb +3 -1
- data/lib/plutonium/ui/display/resource.rb +109 -25
- data/lib/plutonium/ui/display/theme.rb +2 -1
- data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
- data/lib/plutonium/ui/empty_card.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +35 -3
- data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +133 -1
- data/lib/plutonium/ui/form/components/secure_association.rb +105 -24
- data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +45 -10
- data/lib/plutonium/ui/form/theme.rb +1 -1
- data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
- data/lib/plutonium/ui/grid/card.rb +235 -0
- data/lib/plutonium/ui/grid/resource.rb +149 -0
- data/lib/plutonium/ui/layout/base.rb +38 -1
- data/lib/plutonium/ui/layout/header.rb +1 -2
- data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
- data/lib/plutonium/ui/layout/sidebar.rb +12 -24
- data/lib/plutonium/ui/layout/topbar.rb +100 -0
- data/lib/plutonium/ui/modal/base.rb +109 -0
- data/lib/plutonium/ui/modal/centered.rb +21 -0
- data/lib/plutonium/ui/modal/slideover.rb +26 -0
- data/lib/plutonium/ui/page/base.rb +18 -6
- data/lib/plutonium/ui/page/edit.rb +13 -1
- data/lib/plutonium/ui/page/index.rb +40 -1
- data/lib/plutonium/ui/page/interactive_action.rb +8 -39
- data/lib/plutonium/ui/page/new.rb +13 -1
- data/lib/plutonium/ui/page/show.rb +8 -1
- data/lib/plutonium/ui/page_header.rb +8 -13
- data/lib/plutonium/ui/panel.rb +10 -19
- data/lib/plutonium/ui/sidebar_menu.rb +2 -25
- data/lib/plutonium/ui/tab_list.rb +29 -7
- data/lib/plutonium/ui/table/base.rb +106 -0
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
- data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
- data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
- data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
- data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
- data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
- data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
- data/lib/plutonium/ui/table/resource.rb +158 -89
- data/lib/plutonium/ui/table/theme.rb +14 -5
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +14 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/components.css +304 -131
- data/src/css/slim_select.css +4 -0
- data/src/css/tokens.css +101 -85
- data/src/js/controllers/autosubmit_controller.js +24 -0
- data/src/js/controllers/bulk_actions_controller.js +15 -16
- data/src/js/controllers/capture_url_controller.js +14 -0
- data/src/js/controllers/filter_panel_controller.js +77 -19
- data/src/js/controllers/frame_navigator_controller.js +34 -6
- data/src/js/controllers/icon_rail_controller.js +22 -0
- data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
- data/src/js/controllers/register_controllers.js +16 -0
- data/src/js/controllers/resource_tab_list_controller.js +56 -3
- data/src/js/controllers/row_click_controller.js +21 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/controllers/table_column_menu_controller.js +43 -0
- data/src/js/controllers/table_header_controller.js +16 -0
- data/src/js/controllers/view_switcher_controller.js +29 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +71 -32
- 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 -1138
- 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 -325
- 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 -592
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -449
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -268
- 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 -501
|
@@ -78,6 +78,8 @@ module Plutonium
|
|
|
78
78
|
|
|
79
79
|
def BuildTableScopesBar(...) = Plutonium::UI::Table::Components::ScopesBar.new(...)
|
|
80
80
|
|
|
81
|
+
def BuildTableScopesPills(...) = Plutonium::UI::Table::Components::ScopesPills.new(...)
|
|
82
|
+
|
|
81
83
|
def BuildTableInfo(...) = Plutonium::UI::Table::Components::PagyInfo.new(...)
|
|
82
84
|
|
|
83
85
|
def BuildTablePagination(...) = Plutonium::UI::Table::Components::PagyPagination.new(...)
|
|
@@ -86,7 +88,17 @@ module Plutonium
|
|
|
86
88
|
|
|
87
89
|
def BuildBulkActionsToolbar(...) = Plutonium::UI::Table::Components::BulkActionsToolbar.new(...)
|
|
88
90
|
|
|
91
|
+
def BuildTableToolbar(...) = Plutonium::UI::Table::Components::Toolbar.new(...)
|
|
92
|
+
|
|
93
|
+
def BuildTableFilterPills(...) = Plutonium::UI::Table::Components::FilterPills.new(...)
|
|
94
|
+
|
|
95
|
+
def BuildTableViewSwitcher(...) = Plutonium::UI::Table::Components::ViewSwitcher.new(...)
|
|
96
|
+
|
|
89
97
|
def BuildColorModeSelector(...) = Plutonium::UI::ColorModeSelector.new(...)
|
|
98
|
+
|
|
99
|
+
def BuildModalCentered(...) = Plutonium::UI::Modal::Centered.new(...)
|
|
100
|
+
|
|
101
|
+
def BuildModalSlideover(...) = Plutonium::UI::Modal::Slideover.new(...)
|
|
90
102
|
end
|
|
91
103
|
end
|
|
92
104
|
end
|
|
@@ -40,10 +40,14 @@ module Plutonium
|
|
|
40
40
|
:current_user,
|
|
41
41
|
:current_parent,
|
|
42
42
|
:current_definition,
|
|
43
|
+
:resource_definition,
|
|
43
44
|
:current_query_object,
|
|
44
45
|
:raw_resource_query_params,
|
|
45
46
|
:current_policy,
|
|
46
47
|
:current_turbo_frame,
|
|
48
|
+
:in_frame?,
|
|
49
|
+
:in_modal?,
|
|
50
|
+
:in_secondary_modal?,
|
|
47
51
|
:current_interactive_action,
|
|
48
52
|
:current_engine,
|
|
49
53
|
:policy_for,
|
|
@@ -13,56 +13,93 @@ module Plutonium
|
|
|
13
13
|
@resource_definition = resource_definition
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Metadata fields the user is permitted to see — intersection of
|
|
17
|
+
# the definition's declared metadata with the policy-filtered
|
|
18
|
+
# `resource_fields`. Computed lazily so we don't run the
|
|
19
|
+
# intersection when the resource doesn't declare any metadata.
|
|
20
|
+
def metadata_fields
|
|
21
|
+
@metadata_fields ||= resource_definition.defined_metadata_fields & resource_fields
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
def display_template
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
if associations_present?
|
|
26
|
+
render_tablist_with_details
|
|
27
|
+
else
|
|
28
|
+
render_fields
|
|
29
|
+
end
|
|
19
30
|
end
|
|
20
31
|
|
|
21
32
|
private
|
|
22
33
|
|
|
34
|
+
def associations_present?
|
|
35
|
+
present_associations? && resource_associations.present?
|
|
36
|
+
end
|
|
37
|
+
|
|
23
38
|
def render_fields
|
|
39
|
+
if metadata_fields.any?
|
|
40
|
+
div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start") do
|
|
41
|
+
div { render_main_field_card }
|
|
42
|
+
aside { render_metadata_panel }
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
render_main_field_card
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_main_field_card
|
|
24
50
|
Block do
|
|
25
51
|
fields_wrapper do
|
|
26
|
-
|
|
52
|
+
# Skip fields claimed by the metadata panel — rendering
|
|
53
|
+
# them in both places duplicates information.
|
|
54
|
+
(resource_fields - metadata_fields).each do |name|
|
|
27
55
|
render_resource_field name
|
|
28
56
|
end
|
|
29
57
|
end
|
|
30
58
|
end
|
|
31
59
|
end
|
|
32
60
|
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
# Renders the declared metadata fields as a vertical stack beside
|
|
62
|
+
# the main field card. Reuses render_resource_field (same path
|
|
63
|
+
# the main details use) so labels/values match in style; the
|
|
64
|
+
# only difference from the main card is the wrapper — a single
|
|
65
|
+
# column of fields instead of the form's multi-column grid.
|
|
66
|
+
def render_metadata_panel
|
|
67
|
+
Block do
|
|
68
|
+
div(class: "pu-card-body flex flex-col gap-6") do
|
|
69
|
+
metadata_fields.each { |name| render_resource_field(name) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
35
73
|
|
|
74
|
+
def render_tablist_with_details
|
|
36
75
|
tablist = BuildTabList()
|
|
37
76
|
|
|
77
|
+
# Build an inner display component for the Details tab.
|
|
78
|
+
# It must be a standalone Phlex component so that TabList can call
|
|
79
|
+
# `render(details_display)` from within its own context. Phlex propagates
|
|
80
|
+
# @_state through render calls, so the inner component writes to the same
|
|
81
|
+
# buffer as the outer Resource display even though self changes.
|
|
82
|
+
details_display = build_details_display
|
|
83
|
+
|
|
84
|
+
tablist.with_tab(
|
|
85
|
+
identifier: "details",
|
|
86
|
+
title: -> { plain "Details" }
|
|
87
|
+
) do
|
|
88
|
+
render details_display
|
|
89
|
+
end
|
|
90
|
+
|
|
38
91
|
resource_associations.each do |name|
|
|
39
92
|
reflection = object.class.reflect_on_association name
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
raise ArgumentError,
|
|
43
|
-
"unknown association #{object.class}##{name} defined in #permitted_associations"
|
|
44
|
-
elsif !registered_resources.include?(reflection.klass)
|
|
45
|
-
raise ArgumentError,
|
|
46
|
-
"#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
|
|
47
|
-
end
|
|
93
|
+
raise_unknown_association(name) unless reflection
|
|
94
|
+
raise_unregistered_association(name, reflection) unless registered_resources.include?(reflection.klass)
|
|
48
95
|
|
|
49
96
|
title = object.class.human_attribute_name(name)
|
|
50
|
-
src =
|
|
51
|
-
when :belongs_to
|
|
52
|
-
associated = object.public_send name
|
|
53
|
-
resource_url_for(associated, parent: nil) if associated
|
|
54
|
-
when :has_one
|
|
55
|
-
associated = object.public_send name
|
|
56
|
-
resource_url_for(associated, parent: object, association: name)
|
|
57
|
-
when :has_many
|
|
58
|
-
resource_url_for(reflection.klass, parent: object, association: name)
|
|
59
|
-
end
|
|
60
|
-
|
|
97
|
+
src = association_src(name, reflection)
|
|
61
98
|
next unless src
|
|
62
99
|
|
|
63
100
|
tablist.with_tab(
|
|
64
101
|
identifier: title.parameterize,
|
|
65
|
-
title: -> {
|
|
102
|
+
title: -> { plain title }
|
|
66
103
|
) do
|
|
67
104
|
FrameNavigatorPanel(title: "", src:, panel_id: "association-panel-#{title.parameterize}")
|
|
68
105
|
end
|
|
@@ -71,6 +108,53 @@ module Plutonium
|
|
|
71
108
|
render tablist
|
|
72
109
|
end
|
|
73
110
|
|
|
111
|
+
# Builds a standalone Phlex component whose sole job is to render the
|
|
112
|
+
# resource fields. Having a distinct component lets TabList call
|
|
113
|
+
# `render(details_display)` so that Phlex propagates its @_state correctly,
|
|
114
|
+
# while avoiding the `instance_exec` context-switch problem that would
|
|
115
|
+
# occur if we put `render_fields` directly inside the `with_tab` block.
|
|
116
|
+
#
|
|
117
|
+
# The anonymous subclass overrides `view_template` to skip the outer
|
|
118
|
+
# `display_wrapper` div (which would duplicate the dom id already emitted
|
|
119
|
+
# by the parent Resource display) and renders just the fields content.
|
|
120
|
+
def build_details_display
|
|
121
|
+
resource = self
|
|
122
|
+
|
|
123
|
+
klass = Class.new(self.class) do
|
|
124
|
+
define_method(:view_template) do
|
|
125
|
+
resource.send(:render_fields)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
klass.new(
|
|
130
|
+
object,
|
|
131
|
+
resource_fields: resource_fields,
|
|
132
|
+
resource_associations: [],
|
|
133
|
+
resource_definition: resource_definition
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def association_src(name, reflection)
|
|
138
|
+
case reflection.macro
|
|
139
|
+
when :belongs_to
|
|
140
|
+
associated = object.public_send name
|
|
141
|
+
resource_url_for(associated, parent: nil) if associated
|
|
142
|
+
when :has_one
|
|
143
|
+
associated = object.public_send name
|
|
144
|
+
resource_url_for(associated, parent: object, association: name)
|
|
145
|
+
when :has_many
|
|
146
|
+
resource_url_for(reflection.klass, parent: object, association: name)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def raise_unknown_association(name)
|
|
151
|
+
raise ArgumentError, "unknown association #{object.class}##{name} defined in #permitted_associations"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def raise_unregistered_association(name, reflection)
|
|
155
|
+
raise ArgumentError, "#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
|
|
156
|
+
end
|
|
157
|
+
|
|
74
158
|
def render_resource_field(name)
|
|
75
159
|
when_permitted(name) do
|
|
76
160
|
# field :name, as: :string
|
|
@@ -8,7 +8,8 @@ module Plutonium
|
|
|
8
8
|
super.merge({
|
|
9
9
|
base: "",
|
|
10
10
|
value_wrapper: "max-h-[300px] overflow-y-auto",
|
|
11
|
-
fields_wrapper: "
|
|
11
|
+
fields_wrapper: "pu-card",
|
|
12
|
+
fields_inner: "pu-card-body grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
|
|
12
13
|
|
|
13
14
|
# Labels and descriptions
|
|
14
15
|
label: "text-sm font-semibold uppercase tracking-wide text-[var(--pu-text-muted)] mb-2",
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
module Plutonium
|
|
2
2
|
module UI
|
|
3
3
|
module DynaFrame
|
|
4
|
+
# Conditionally wraps its content in a turbo-frame matching the inbound
|
|
5
|
+
# request's `Turbo-Frame` header. In frame mode adds the flash partial
|
|
6
|
+
# so toast/alert messages still surface inside frames; in non-frame
|
|
7
|
+
# mode renders the block as-is.
|
|
4
8
|
class Content < Plutonium::UI::Component::Base
|
|
5
9
|
include Phlex::Rails::Helpers::TurboFrameTag
|
|
6
10
|
|
|
7
|
-
def
|
|
8
|
-
@content = content
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def view_template
|
|
11
|
+
def view_template(&block)
|
|
12
12
|
if current_turbo_frame.present?
|
|
13
|
-
# Frame request: render only the turbo-frame with content
|
|
14
13
|
turbo_frame_tag(current_turbo_frame) do
|
|
15
14
|
render partial("flash")
|
|
16
|
-
|
|
15
|
+
yield if block_given?
|
|
17
16
|
end
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
yield(self)
|
|
17
|
+
elsif block_given?
|
|
18
|
+
yield
|
|
21
19
|
end
|
|
22
20
|
end
|
|
23
|
-
|
|
24
|
-
def render_content
|
|
25
|
-
@content&.call
|
|
26
|
-
end
|
|
27
21
|
end
|
|
28
22
|
end
|
|
29
23
|
end
|
|
@@ -10,6 +10,28 @@ module Plutonium
|
|
|
10
10
|
include Phlexi::Field::Common::Tokens
|
|
11
11
|
include Plutonium::UI::Form::Options::InferredTypes
|
|
12
12
|
|
|
13
|
+
# Consume `:as` here so it doesn't land in Phlexi's `@options` —
|
|
14
|
+
# `:as` is a Plutonium-internal concept (it picks the tag method),
|
|
15
|
+
# not a Phlexi field option.
|
|
16
|
+
def initialize(*args, as: nil, **kwargs, &block)
|
|
17
|
+
@as = as
|
|
18
|
+
super(*args, **kwargs, &block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :as
|
|
22
|
+
|
|
23
|
+
def hidden?
|
|
24
|
+
as.to_s == "hidden"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Hidden fields (`form.field(name, as: :hidden)`) skip the label /
|
|
28
|
+
# hint / error chrome and render inside a `<div hidden>` so they're
|
|
29
|
+
# also excluded from CSS Grid / Flex layout.
|
|
30
|
+
def wrapped(**, &)
|
|
31
|
+
return Plutonium::UI::Form::Components::HiddenWrapper.new(self, &) if hidden?
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
13
35
|
def textarea_tag(**attributes, &)
|
|
14
36
|
attributes[:data_controller] = tokens(attributes[:data_controller], "textarea-autogrow")
|
|
15
37
|
super
|
|
@@ -44,8 +66,18 @@ module Plutonium
|
|
|
44
66
|
create_component(Components::KeyValueStore, :key_value_store, **, &)
|
|
45
67
|
end
|
|
46
68
|
|
|
69
|
+
def json_input_tag(**, &)
|
|
70
|
+
create_component(Components::Json, :json, **, &)
|
|
71
|
+
end
|
|
72
|
+
|
|
47
73
|
def resource_select_tag(**attributes, &)
|
|
48
|
-
|
|
74
|
+
attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
|
|
75
|
+
# class!: "" clears the underlying <select>'s themed classes
|
|
76
|
+
# (pu-input etc.) — the visible element is slim-select's
|
|
77
|
+
# generated .ss-main, so leaving Tailwind input chrome on the
|
|
78
|
+
# native select can leak into chip layout (e.g. forcing
|
|
79
|
+
# flex-direction: column or w-full on multi-mode chips).
|
|
80
|
+
create_component(Components::ResourceSelect, :select, class!: "", **attributes, &)
|
|
49
81
|
end
|
|
50
82
|
|
|
51
83
|
def secure_association_tag(**attributes, &)
|
|
@@ -79,8 +111,8 @@ module Plutonium
|
|
|
79
111
|
alias_method :date_tag, :flatpickr_tag
|
|
80
112
|
alias_method :time_tag, :flatpickr_tag
|
|
81
113
|
alias_method :rich_text_tag, :markdown_tag
|
|
82
|
-
alias_method :json_tag, :
|
|
83
|
-
alias_method :jsonb_tag, :
|
|
114
|
+
alias_method :json_tag, :json_input_tag
|
|
115
|
+
alias_method :jsonb_tag, :json_input_tag
|
|
84
116
|
alias_method :hstore_tag, :key_value_store_tag
|
|
85
117
|
alias_method :key_value_tag, :key_value_store_tag
|
|
86
118
|
alias_method :association_tag, :secure_association_tag
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# Wrapper for fields configured as `as: :hidden`. Emits a hidden div
|
|
8
|
+
# containing only the input — no label, no hint, no error chrome.
|
|
9
|
+
# `hidden: true` (HTML5) sets `display: none` so the wrapper is
|
|
10
|
+
# excluded from CSS Grid / Flex layout, not just visually hidden.
|
|
11
|
+
class HiddenWrapper < Phlexi::Form::Components::Base
|
|
12
|
+
def view_template(&block)
|
|
13
|
+
div(hidden: true) do
|
|
14
|
+
yield(field) if block
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# No id needed for a layout-suppressed wrapper.
|
|
19
|
+
def build_attributes
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Plutonium
|
|
6
|
+
module UI
|
|
7
|
+
module Form
|
|
8
|
+
module Components
|
|
9
|
+
# Textarea-based input for `json` / `jsonb` columns.
|
|
10
|
+
#
|
|
11
|
+
# On render, serializes Hash/Array values to pretty JSON so users see
|
|
12
|
+
# valid JSON instead of Ruby `Hash#to_s` output (e.g. `{:k=>"v"}`).
|
|
13
|
+
# Strings are pretty-formatted if parseable, passed through verbatim
|
|
14
|
+
# otherwise — preserves an in-progress edit on form re-render.
|
|
15
|
+
#
|
|
16
|
+
# On submit, accepts either a JSON string (typed input) or a raw
|
|
17
|
+
# Hash/Array (e.g. a JSON-bodied API request that Rails has already
|
|
18
|
+
# parsed into params). Unparseable strings are passed through so model
|
|
19
|
+
# validation can surface the error, rather than being silently dropped.
|
|
20
|
+
class Json < Phlexi::Form::Components::Textarea
|
|
21
|
+
def view_template
|
|
22
|
+
textarea(**attributes) { serialized_value }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
protected
|
|
26
|
+
|
|
27
|
+
def serialized_value
|
|
28
|
+
case (raw = field.value)
|
|
29
|
+
when nil then ""
|
|
30
|
+
when String then format_string(raw)
|
|
31
|
+
else JSON.pretty_generate(raw)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def format_string(str)
|
|
36
|
+
JSON.pretty_generate(JSON.parse(str))
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
str
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_input(input_value)
|
|
42
|
+
case input_value
|
|
43
|
+
when nil then nil
|
|
44
|
+
when Hash, Array then input_value
|
|
45
|
+
when "" then nil
|
|
46
|
+
else
|
|
47
|
+
begin
|
|
48
|
+
JSON.parse(input_value)
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
input_value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -6,24 +6,156 @@ module Plutonium
|
|
|
6
6
|
module Components
|
|
7
7
|
# Select for choosing a resource record
|
|
8
8
|
class ResourceSelect < Phlexi::Form::Components::Select
|
|
9
|
+
include Plutonium::UI::Component::Methods
|
|
10
|
+
include Plutonium::UI::Form::Concerns::TypeaheadAttributes
|
|
11
|
+
|
|
12
|
+
# Cap on the number of records the dropdown materialises. Keeps
|
|
13
|
+
# very large association tables from rendering thousands of
|
|
14
|
+
# options into the page; consumers needing more should pair this
|
|
15
|
+
# with a typeahead control later.
|
|
16
|
+
DEFAULT_CHOICE_LIMIT = 100
|
|
17
|
+
|
|
9
18
|
protected
|
|
10
19
|
|
|
11
20
|
def choices
|
|
12
21
|
@choices ||= begin
|
|
13
|
-
collection = @raw_choices
|
|
22
|
+
collection = if @raw_choices
|
|
23
|
+
@raw_choices
|
|
24
|
+
elsif @association_class.nil?
|
|
25
|
+
[]
|
|
26
|
+
else
|
|
27
|
+
authorized_relation(limit: @choice_limit)
|
|
28
|
+
end
|
|
14
29
|
build_choice_mapper(collection)
|
|
15
30
|
end
|
|
16
31
|
end
|
|
17
32
|
|
|
33
|
+
# Builds the authorized relation for the association class, optionally
|
|
34
|
+
# capped at `limit`. Shared by `choices` (with the limit) and
|
|
35
|
+
# `normalize_simple_input` (without — so typeahead picks beyond the
|
|
36
|
+
# rendered subset still validate).
|
|
37
|
+
def authorized_relation(limit: nil)
|
|
38
|
+
relation = @association_class.all
|
|
39
|
+
relation = relation.limit(limit) if limit && relation.respond_to?(:limit)
|
|
40
|
+
return relation if @skip_authorization
|
|
41
|
+
authorized_resource_scope(@association_class, relation: relation)
|
|
42
|
+
end
|
|
43
|
+
|
|
18
44
|
def build_attributes
|
|
45
|
+
# Defaults must land BEFORE super — AcceptsChoices.build_attributes
|
|
46
|
+
# consumes :value_method / :label_method off `attributes` into
|
|
47
|
+
# its own ivars, so anything we set after super has no effect.
|
|
48
|
+
attributes[:value_method] ||= :to_signed_global_id
|
|
49
|
+
attributes[:label_method] ||= :to_label
|
|
50
|
+
|
|
19
51
|
super
|
|
52
|
+
|
|
20
53
|
@association_class = attributes.delete(:association_class)
|
|
54
|
+
@skip_authorization = attributes.delete(:skip_authorization)
|
|
55
|
+
@choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
|
|
56
|
+
attributes.delete(:choice_limit)
|
|
57
|
+
# Stash the typeahead option; the URL helper needs view_context
|
|
58
|
+
# which only exists once we're rendering.
|
|
59
|
+
@typeahead_option = attributes.delete(:typeahead)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Phlex hook fires right before view_template runs and view_context
|
|
63
|
+
# is available, so this is where we can resolve the typeahead URL
|
|
64
|
+
# and inject the data attr.
|
|
65
|
+
def before_template
|
|
66
|
+
super
|
|
67
|
+
configure_typeahead_attributes!(@typeahead_option)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# SGIDs include a timestamp + signature, so the SGID in the URL
|
|
71
|
+
# (generated when the user submitted) won't string-equal the
|
|
72
|
+
# SGID we just generated for the same record. Compare by the
|
|
73
|
+
# decoded model id instead, falling back to raw string equality
|
|
74
|
+
# for non-SGID values (legacy URLs / explicit raw choices).
|
|
75
|
+
def selected?(option)
|
|
76
|
+
if attributes[:multiple]
|
|
77
|
+
Array(field.value).any? { |v| same_record?(v, option) }
|
|
78
|
+
else
|
|
79
|
+
same_record?(field.value, option)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# AcceptsChoices.normalize_simple_input rejects any submitted
|
|
84
|
+
# value that doesn't string-match an option's value. With SGIDs
|
|
85
|
+
# the URL value (signed/timestamped at submit time) never
|
|
86
|
+
# string-equals a freshly generated option SGID for the same
|
|
87
|
+
# record, so the value gets silently dropped — no WHERE clause
|
|
88
|
+
# is built and the filter behaves as if it weren't applied.
|
|
89
|
+
#
|
|
90
|
+
# For SGID values backed by an association class, validate against
|
|
91
|
+
# the unbounded authorized relation rather than the rendered
|
|
92
|
+
# `choices` (which is capped at `choice_limit` and may not include
|
|
93
|
+
# records reachable via typeahead). For raw values / explicit
|
|
94
|
+
# `@raw_choices`, fall back to record-equality against the rendered
|
|
95
|
+
# options.
|
|
96
|
+
def normalize_simple_input(input_value)
|
|
97
|
+
return nil if input_value.blank?
|
|
98
|
+
|
|
99
|
+
sgid = SignedGlobalID.parse(input_value)
|
|
100
|
+
if sgid && @association_class && !@raw_choices
|
|
101
|
+
return nil unless sgid.model_class <= @association_class
|
|
102
|
+
return authorized_relation.exists?(id: sgid.model_id) ? input_value : nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
choices.values.find { |opt| same_record?(input_value, opt) } && input_value
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Two values point at the same record when both decode to the
|
|
109
|
+
# same SGID (class + id). For explicit non-SGID `@raw_choices`,
|
|
110
|
+
# both sides are plain strings and string-equality is the only
|
|
111
|
+
# sensible answer. Mixed-format (one SGID, one raw) returns
|
|
112
|
+
# false — no cross-format guessing.
|
|
113
|
+
def same_record?(a, b)
|
|
114
|
+
return false if a.blank? || b.blank?
|
|
115
|
+
|
|
116
|
+
a_pair = decode_class_and_id(a)
|
|
117
|
+
b_pair = decode_class_and_id(b)
|
|
118
|
+
return a_pair == b_pair if a_pair && b_pair
|
|
119
|
+
return false if a_pair || b_pair
|
|
120
|
+
|
|
121
|
+
a.to_s == b.to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def decode_class_and_id(value)
|
|
125
|
+
gid = SignedGlobalID.parse(value)
|
|
126
|
+
gid && [gid.model_class.name, gid.model_id]
|
|
127
|
+
rescue
|
|
128
|
+
nil
|
|
21
129
|
end
|
|
22
130
|
|
|
23
131
|
# Use include_blank string as blank option text (Phlexi default uses placeholder)
|
|
24
132
|
def blank_option_text
|
|
25
133
|
@include_blank.is_a?(String) ? @include_blank : super
|
|
26
134
|
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def typeahead_target_class
|
|
139
|
+
@association_class
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def typeahead_kind_and_name(_typeahead_option)
|
|
143
|
+
detect_typeahead_kind_and_name
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Plutonium::UI::Form::Query roots its form with `as: :q`, so
|
|
147
|
+
# any field whose ancestry includes a node keyed :q is a filter
|
|
148
|
+
# input. The filter name is the immediate child of that root.
|
|
149
|
+
# Form inputs (new/edit) fall through to :input + the field key.
|
|
150
|
+
def detect_typeahead_kind_and_name
|
|
151
|
+
lineage = field.dom.lineage
|
|
152
|
+
q_index = lineage.find_index { |node| node.key == :q }
|
|
153
|
+
if q_index && (filter_node = lineage[q_index + 1])
|
|
154
|
+
[:filter, filter_node.key]
|
|
155
|
+
else
|
|
156
|
+
[:input, field.key]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
27
159
|
end
|
|
28
160
|
end
|
|
29
161
|
end
|