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
|
@@ -6,6 +6,9 @@ module Plutonium
|
|
|
6
6
|
module Components
|
|
7
7
|
class SecureAssociation < Phlexi::Form::Components::AssociationBase
|
|
8
8
|
include Plutonium::UI::Component::Methods
|
|
9
|
+
include Plutonium::UI::Form::Concerns::TypeaheadAttributes
|
|
10
|
+
|
|
11
|
+
DEFAULT_CHOICE_LIMIT = Plutonium::UI::Form::Components::ResourceSelect::DEFAULT_CHOICE_LIMIT
|
|
9
12
|
|
|
10
13
|
def view_template
|
|
11
14
|
div(class: "flex items-center space-x-1") do
|
|
@@ -19,50 +22,102 @@ module Plutonium
|
|
|
19
22
|
delegate :association_reflection, to: :field
|
|
20
23
|
|
|
21
24
|
def render_add_button
|
|
22
|
-
return if @add_action == false
|
|
25
|
+
return if @add_action == false
|
|
26
|
+
|
|
27
|
+
url, turbo_frame = add_url_and_frame
|
|
28
|
+
return unless url
|
|
29
|
+
|
|
30
|
+
# When the parent form is already inside a modal, route the
|
|
31
|
+
# "+" to the secondary frame so the stacked dialog opens on
|
|
32
|
+
# top of the original form rather than replacing it. The
|
|
33
|
+
# crud controller mirrors this on success — closing the
|
|
34
|
+
# secondary modal and reloading the primary so the
|
|
35
|
+
# association select picks up the new record.
|
|
36
|
+
if turbo_frame == Plutonium::REMOTE_MODAL_FRAME && in_modal?
|
|
37
|
+
turbo_frame = Plutonium::REMOTE_MODAL_SECONDARY_FRAME
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attrs = {
|
|
41
|
+
href: url,
|
|
42
|
+
class: "inline-flex items-center justify-center w-9 h-9 shrink-0 bg-[var(--pu-surface-alt)] hover:bg-[var(--pu-border)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] focus:ring-2 focus:ring-[var(--pu-border)] focus:outline-none text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors"
|
|
43
|
+
}
|
|
44
|
+
attrs[:data] = {turbo_frame: turbo_frame} if turbo_frame
|
|
23
45
|
|
|
24
|
-
a(
|
|
25
|
-
|
|
26
|
-
class: "bg-[var(--pu-surface-alt)] hover:bg-[var(--pu-border)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] px-4 py-3 focus:ring-2 focus:ring-[var(--pu-border)] focus:outline-none text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors"
|
|
27
|
-
) do
|
|
28
|
-
render Phlex::TablerIcons::Plus.new(class: "w-6 h-6")
|
|
46
|
+
a(**attrs) do
|
|
47
|
+
render Phlex::TablerIcons::Plus.new(class: "w-4 h-4")
|
|
29
48
|
end
|
|
30
49
|
end
|
|
31
50
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
# Resolves the destination for the inline "+" button alongside
|
|
52
|
+
# the association select. We go through the target resource's
|
|
53
|
+
# `:new` action (rather than building a URL by hand) so the
|
|
54
|
+
# button inherits whatever modal/slideover frame the target
|
|
55
|
+
# resource is configured for — same path table/grid use for
|
|
56
|
+
# their own "New" button. A custom string `add_action:` skips
|
|
57
|
+
# the frame lookup since we can't infer the target's modal
|
|
58
|
+
# mode from an arbitrary URL.
|
|
59
|
+
def add_url_and_frame
|
|
60
|
+
klass = association_reflection.klass
|
|
61
|
+
|
|
62
|
+
if @add_action.is_a?(String)
|
|
63
|
+
return [with_return_to(@add_action), nil] if @skip_authorization || allowed_to?(:create?, klass)
|
|
64
|
+
return
|
|
65
|
+
end
|
|
35
66
|
|
|
36
|
-
|
|
37
|
-
|
|
67
|
+
return unless registered_resources.include?(klass)
|
|
68
|
+
action = resource_definition(klass).defined_actions[:new]
|
|
69
|
+
return unless action
|
|
70
|
+
return unless @skip_authorization || action.permitted_by?(policy_for(record: klass))
|
|
38
71
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
url = route_options_to_url(action.route_options, klass)
|
|
73
|
+
[with_return_to(url), action.turbo_frame]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def with_return_to(url)
|
|
77
|
+
uri = URI(url)
|
|
78
|
+
params = Rack::Utils.parse_nested_query(uri.query)
|
|
79
|
+
params["return_to"] = request.original_url
|
|
80
|
+
uri.query = params.to_query
|
|
81
|
+
uri.to_s
|
|
43
82
|
end
|
|
44
83
|
|
|
45
84
|
def choices
|
|
46
85
|
@choices ||= begin
|
|
47
|
-
collection =
|
|
48
|
-
|
|
49
|
-
elsif @skip_authorization
|
|
50
|
-
choices_from_association(association_reflection.klass)
|
|
51
|
-
else
|
|
52
|
-
authorized_resource_scope(association_reflection.klass, relation: choices_from_association(association_reflection.klass))
|
|
53
|
-
end
|
|
86
|
+
collection = @raw_choices || authorized_relation
|
|
87
|
+
collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
|
|
54
88
|
build_choice_mapper(collection)
|
|
55
89
|
end
|
|
56
90
|
end
|
|
57
91
|
|
|
92
|
+
# Builds the authorized association relation. Shared by `choices`
|
|
93
|
+
# (which then applies `choice_limit`) and `normalize_simple_input`
|
|
94
|
+
# (which validates against the full set so typeahead picks beyond
|
|
95
|
+
# the rendered subset still survive submit).
|
|
96
|
+
def authorized_relation
|
|
97
|
+
klass = association_reflection.klass
|
|
98
|
+
relation = choices_from_association(klass)
|
|
99
|
+
return relation if @skip_authorization
|
|
100
|
+
authorized_resource_scope(klass, relation: relation)
|
|
101
|
+
end
|
|
102
|
+
|
|
58
103
|
def build_attributes
|
|
59
104
|
build_association_attributes
|
|
60
105
|
super
|
|
106
|
+
# Stash; the URL helper needs view_context which only exists
|
|
107
|
+
# once we're rendering.
|
|
108
|
+
@typeahead_option = attributes.delete(:typeahead)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def before_template
|
|
112
|
+
super
|
|
113
|
+
configure_typeahead_attributes!(@typeahead_option)
|
|
61
114
|
end
|
|
62
115
|
|
|
63
116
|
def build_association_attributes
|
|
64
117
|
@skip_authorization = attributes.delete(:skip_authorization)
|
|
65
118
|
@add_action = attributes.delete(:add_action)
|
|
119
|
+
@choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
|
|
120
|
+
attributes.delete(:choice_limit)
|
|
66
121
|
|
|
67
122
|
attributes.fetch(:value_method) { attributes[:value_method] = :to_signed_global_id }
|
|
68
123
|
|
|
@@ -74,6 +129,20 @@ module Plutonium
|
|
|
74
129
|
end
|
|
75
130
|
end
|
|
76
131
|
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Polymorphic reflections raise NameError on #klass — they
|
|
135
|
+
# have no single target class to search, so opt out.
|
|
136
|
+
def typeahead_target_class
|
|
137
|
+
return nil unless association_reflection
|
|
138
|
+
return nil if association_reflection.respond_to?(:polymorphic?) && association_reflection.polymorphic?
|
|
139
|
+
association_reflection.klass
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def typeahead_kind_and_name(_typeahead_option)
|
|
143
|
+
[:input, association_reflection.name]
|
|
144
|
+
end
|
|
145
|
+
|
|
77
146
|
def build_singluar_association_attributes
|
|
78
147
|
attributes.fetch(:input_param) { attributes[:input_param] = :"#{association_reflection.name}_sgid" }
|
|
79
148
|
end
|
|
@@ -83,9 +152,21 @@ module Plutonium
|
|
|
83
152
|
attributes[:multiple] = true
|
|
84
153
|
end
|
|
85
154
|
|
|
155
|
+
# Validates a submitted SGID against the authorized association scope
|
|
156
|
+
# (not against `choices`, which is capped at `choice_limit` and may
|
|
157
|
+
# not include records reachable via typeahead). For explicit
|
|
158
|
+
# `@raw_choices`, fall back to membership in the rendered list.
|
|
86
159
|
def normalize_simple_input(input_value)
|
|
87
|
-
|
|
88
|
-
|
|
160
|
+
sgid = SignedGlobalID.parse(input_value.presence)
|
|
161
|
+
return nil unless sgid
|
|
162
|
+
|
|
163
|
+
if @raw_choices
|
|
164
|
+
@signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
|
|
165
|
+
return @signed_global_ids.include?(sgid) ? sgid : nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
return nil unless sgid.model_class <= association_reflection.klass
|
|
169
|
+
authorized_relation.exists?(id: sgid.model_id) ? sgid : nil
|
|
89
170
|
end
|
|
90
171
|
|
|
91
172
|
def selected?(option)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
class StickyFooter < Plutonium::UI::Component::Base
|
|
8
|
+
def view_template(&block)
|
|
9
|
+
div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
|
|
10
|
+
"h-14 bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
|
|
11
|
+
"px-6 flex items-center justify-end gap-2", &block)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Concerns
|
|
7
|
+
# Shared typeahead wiring for association/resource select components.
|
|
8
|
+
#
|
|
9
|
+
# Hosts must implement two hooks:
|
|
10
|
+
#
|
|
11
|
+
# typeahead_target_class -> the associated model class, or nil
|
|
12
|
+
# (returns nil for polymorphic/unknown)
|
|
13
|
+
# typeahead_kind_and_name(opt) -> [:input | :filter, Symbol], used when
|
|
14
|
+
# the consumer didn't pass an explicit
|
|
15
|
+
# `typeahead: {kind:, name:}` hash
|
|
16
|
+
#
|
|
17
|
+
# The concern owns:
|
|
18
|
+
# - `configure_typeahead_attributes!` — call from `before_template`
|
|
19
|
+
# - `typeahead_searchable?` — registry + fallback-column check
|
|
20
|
+
# - `typeahead_url_for` — engine route helper lookup
|
|
21
|
+
module TypeaheadAttributes
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Adds the typeahead URL data attr so the slim-select Stimulus
|
|
27
|
+
# controller delegates to the backend via events.search.
|
|
28
|
+
# Default-on (opt out with `typeahead: false`). Pass a Hash to
|
|
29
|
+
# override kind/name (e.g. `typeahead: {kind: :filter, name: :status}`).
|
|
30
|
+
#
|
|
31
|
+
# Auto opt-out: if the associated resource has neither a `search`
|
|
32
|
+
# block nor a fallback search column on the model, fall back to
|
|
33
|
+
# slim-select's eager list + client-side filter — the backend
|
|
34
|
+
# would just return unfiltered records.
|
|
35
|
+
def configure_typeahead_attributes!(typeahead_option)
|
|
36
|
+
return if typeahead_option == false
|
|
37
|
+
return unless typeahead_searchable?
|
|
38
|
+
url = typeahead_url_for(typeahead_option)
|
|
39
|
+
return unless url
|
|
40
|
+
attributes[:data_slim_select_typeahead_url_value] = url
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def typeahead_searchable?
|
|
44
|
+
klass = typeahead_target_class
|
|
45
|
+
return false unless klass
|
|
46
|
+
|
|
47
|
+
# Go through `resource_definition` so portal/package namespacing
|
|
48
|
+
# is honored — a portal can ship its own definition with a
|
|
49
|
+
# different `search` block than the base.
|
|
50
|
+
return true if resource_definition(klass).class._search_definition.present?
|
|
51
|
+
|
|
52
|
+
Plutonium::Resource::Controllers::Typeahead
|
|
53
|
+
.searchable_column_for(klass, label_method: @label_method).present?
|
|
54
|
+
rescue NameError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def typeahead_url_for(typeahead_option)
|
|
59
|
+
kind, name = if typeahead_option.is_a?(Hash)
|
|
60
|
+
[typeahead_option[:kind] || :input, typeahead_option[:name]]
|
|
61
|
+
else
|
|
62
|
+
typeahead_kind_and_name(typeahead_option)
|
|
63
|
+
end
|
|
64
|
+
return nil unless name
|
|
65
|
+
|
|
66
|
+
route_key = resource_class.model_name.route_key
|
|
67
|
+
helper = (kind == :filter) ? :"typeahead_filter_#{route_key}_path" : :"typeahead_input_#{route_key}_path"
|
|
68
|
+
|
|
69
|
+
# Engine route helpers are the source of truth for routes
|
|
70
|
+
# mounted under a Plutonium portal — phlex-rails' `helpers`
|
|
71
|
+
# proxy is deprecated and not the right entry point here.
|
|
72
|
+
# Helper may be absent if a consumer removed the typeahead
|
|
73
|
+
# route from the resource — fall back to no URL, slim-select
|
|
74
|
+
# uses its eager list.
|
|
75
|
+
url_helpers = current_engine.routes.url_helpers
|
|
76
|
+
return nil unless url_helpers.respond_to?(helper)
|
|
77
|
+
url_helpers.public_send(helper, name: name)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -18,10 +18,23 @@ module Plutonium
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def form_template
|
|
21
|
-
|
|
21
|
+
if in_modal?
|
|
22
|
+
# In modal: form is the flex container that fills the modal
|
|
23
|
+
# body. Fields region scrolls; action strip sits flush at the
|
|
24
|
+
# bottom edge of the modal.
|
|
25
|
+
div(class: "flex-1 min-h-0 overflow-y-auto px-6 py-5") do
|
|
26
|
+
render_fields
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
render_fields
|
|
30
|
+
end
|
|
22
31
|
render_actions
|
|
23
32
|
end
|
|
24
33
|
|
|
34
|
+
def form_class
|
|
35
|
+
in_modal? ? "flex-1 flex flex-col min-h-0" : super
|
|
36
|
+
end
|
|
37
|
+
|
|
25
38
|
private
|
|
26
39
|
|
|
27
40
|
def render_fields
|
|
@@ -33,18 +46,39 @@ module Plutonium
|
|
|
33
46
|
end
|
|
34
47
|
|
|
35
48
|
def render_actions
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
# capture-url controller sets this element's value to
|
|
50
|
+
# window.location.href on connect, so URL fragments (#tab-id)
|
|
51
|
+
# survive the redirect after submit (the server never sees them).
|
|
52
|
+
input name: "return_to",
|
|
53
|
+
value: request.params[:return_to] || request.original_url,
|
|
54
|
+
type: :hidden,
|
|
55
|
+
hidden: true,
|
|
56
|
+
data: {controller: "capture-url"}
|
|
57
|
+
|
|
58
|
+
if in_modal?
|
|
59
|
+
div(class: "shrink-0 px-6 py-3 " \
|
|
60
|
+
"bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
|
|
61
|
+
"flex items-center justify-end gap-2") do
|
|
62
|
+
render_submit_and_continue_button if show_submit_and_continue?
|
|
63
|
+
render submit_button
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
render Plutonium::UI::Form::Components::StickyFooter.new do
|
|
67
|
+
render_submit_and_continue_button if show_submit_and_continue?
|
|
68
|
+
render submit_button
|
|
69
|
+
end
|
|
70
|
+
end
|
|
43
71
|
end
|
|
44
72
|
|
|
45
73
|
def show_submit_and_continue?
|
|
46
74
|
return false unless object.respond_to?(:new_record?)
|
|
47
75
|
|
|
76
|
+
# Continue / add-another lands on the form's standalone URL —
|
|
77
|
+
# which breaks the experience when the form is inside a frame
|
|
78
|
+
# (modal or association tab) since the redirect can't keep the
|
|
79
|
+
# user in that frame context.
|
|
80
|
+
return false if current_turbo_frame.present?
|
|
81
|
+
|
|
48
82
|
# Check explicit configuration first
|
|
49
83
|
configured = resource_definition.submit_and_continue
|
|
50
84
|
return configured unless configured.nil?
|
|
@@ -60,7 +94,7 @@ module Plutonium
|
|
|
60
94
|
type: :submit,
|
|
61
95
|
name: "return_to",
|
|
62
96
|
value: request.url,
|
|
63
|
-
class: "
|
|
97
|
+
class: "pu-btn pu-btn-md pu-btn-outline"
|
|
64
98
|
) { label }
|
|
65
99
|
end
|
|
66
100
|
|
|
@@ -116,7 +150,8 @@ module Plutonium
|
|
|
116
150
|
end
|
|
117
151
|
end
|
|
118
152
|
|
|
119
|
-
|
|
153
|
+
# Keep `:as` so the Builder can detect hidden fields via `options[:as]`.
|
|
154
|
+
field_options = field_options.except(:condition)
|
|
120
155
|
|
|
121
156
|
condition = input_options[:condition] || field_options[:condition]
|
|
122
157
|
conditionally_hidden = condition && !instance_exec(&condition)
|
|
@@ -10,12 +10,14 @@ module Plutonium
|
|
|
10
10
|
|
|
11
11
|
def view_template
|
|
12
12
|
button(
|
|
13
|
+
type: "button",
|
|
13
14
|
title: @label,
|
|
15
|
+
aria: {label: @label},
|
|
14
16
|
style: "display: none",
|
|
15
|
-
class: "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors",
|
|
17
|
+
class: "inline-flex items-center justify-center w-7 h-7 rounded text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors",
|
|
16
18
|
**@attributes
|
|
17
19
|
) {
|
|
18
|
-
render @icon.new(class: "w-
|
|
20
|
+
render @icon.new(class: "w-4 h-4")
|
|
19
21
|
}
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -31,11 +33,12 @@ module Plutonium
|
|
|
31
33
|
def view_template
|
|
32
34
|
a(
|
|
33
35
|
title: @label,
|
|
34
|
-
|
|
36
|
+
aria: {label: @label},
|
|
35
37
|
href: @href,
|
|
38
|
+
class: "inline-flex items-center justify-center w-7 h-7 rounded text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors",
|
|
36
39
|
**@attributes
|
|
37
40
|
) {
|
|
38
|
-
render @icon.new(class: "w-
|
|
41
|
+
render @icon.new(class: "w-4 h-4")
|
|
39
42
|
}
|
|
40
43
|
end
|
|
41
44
|
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Grid
|
|
6
|
+
# Renders a single record as a card built from semantic slots
|
|
7
|
+
# (image / header / subheader / body / meta / footer) declared via
|
|
8
|
+
# `grid_fields` on the resource definition. Each slot is optional;
|
|
9
|
+
# `header` falls back to `record.to_label` when undeclared.
|
|
10
|
+
class Card < Plutonium::UI::Component::Base
|
|
11
|
+
attr_reader :record, :resource_definition, :resource_fields
|
|
12
|
+
|
|
13
|
+
def initialize(record, resource_definition:, resource_fields: nil)
|
|
14
|
+
@record = record
|
|
15
|
+
@resource_definition = resource_definition
|
|
16
|
+
@resource_fields = resource_fields
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def view_template
|
|
20
|
+
article(
|
|
21
|
+
class: card_class,
|
|
22
|
+
data: {controller: "row-click", action: "click->row-click#click"}
|
|
23
|
+
) do
|
|
24
|
+
render_show_link if can_show?
|
|
25
|
+
render_actions_dropdown
|
|
26
|
+
case resource_definition.defined_grid_layout
|
|
27
|
+
when :media then render_media_layout
|
|
28
|
+
else render_compact_layout
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def slots = resource_definition.defined_grid_fields
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------
|
|
38
|
+
# Layout shells
|
|
39
|
+
# ---------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def render_compact_layout
|
|
42
|
+
div(class: "flex items-start gap-3 p-4") do
|
|
43
|
+
render_image_slot(size: :sm) if slots[:image]
|
|
44
|
+
div(class: "min-w-0 flex-1 flex flex-col gap-1") do
|
|
45
|
+
render_header_slot
|
|
46
|
+
render_subheader_slot if slots[:subheader]
|
|
47
|
+
render_body_slot if slots[:body]
|
|
48
|
+
render_meta_slot if slots[:meta]
|
|
49
|
+
render_footer_slot if footer_field
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_media_layout
|
|
55
|
+
render_image_slot(size: :cover) if slots[:image]
|
|
56
|
+
div(class: "p-4 flex flex-col gap-1") do
|
|
57
|
+
render_header_slot
|
|
58
|
+
render_subheader_slot if slots[:subheader]
|
|
59
|
+
render_body_slot if slots[:body]
|
|
60
|
+
render_meta_slot if slots[:meta]
|
|
61
|
+
render_footer_slot if footer_field
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Footer falls back to `:created_at` when the slot is unset and
|
|
66
|
+
# the record has a created_at column. Gives cards a sensible
|
|
67
|
+
# second line without forcing every grid_fields call to repeat it.
|
|
68
|
+
def footer_field
|
|
69
|
+
slots[:footer] || (record.respond_to?(:created_at) ? :created_at : nil)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------
|
|
73
|
+
# Slot renderers
|
|
74
|
+
# ---------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def render_image_slot(size:)
|
|
77
|
+
value = field_value(slots[:image])
|
|
78
|
+
return unless value
|
|
79
|
+
src = image_src_for(value)
|
|
80
|
+
return unless src
|
|
81
|
+
|
|
82
|
+
if size == :cover
|
|
83
|
+
div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
|
|
84
|
+
img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
img(
|
|
88
|
+
src: src,
|
|
89
|
+
alt: header_text.to_s,
|
|
90
|
+
class: "w-12 h-12 rounded-full object-cover bg-[var(--pu-surface-alt)] shrink-0"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_header_slot
|
|
96
|
+
h3(class: "text-sm font-semibold text-[var(--pu-text)] truncate") do
|
|
97
|
+
plain header_text
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def render_subheader_slot
|
|
102
|
+
value = field_value(slots[:subheader])
|
|
103
|
+
return if value.blank?
|
|
104
|
+
p(class: "text-xs text-[var(--pu-text-muted)] truncate") { plain helpers.display_name_of(value) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def render_body_slot
|
|
108
|
+
value = field_value(slots[:body])
|
|
109
|
+
return if value.blank?
|
|
110
|
+
p(class: "text-sm text-[var(--pu-text)] line-clamp-3") { plain helpers.display_name_of(value) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_meta_slot
|
|
114
|
+
fields = Array(slots[:meta])
|
|
115
|
+
values = fields.map { |f| field_value(f) }.reject(&:blank?)
|
|
116
|
+
return if values.empty?
|
|
117
|
+
|
|
118
|
+
div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
|
|
119
|
+
values.each do |v|
|
|
120
|
+
span(class: "inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium " \
|
|
121
|
+
"bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)]") do
|
|
122
|
+
plain helpers.display_name_of(v)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def render_footer_slot
|
|
129
|
+
value = field_value(footer_field)
|
|
130
|
+
return if value.blank?
|
|
131
|
+
p(class: "text-xs text-[var(--pu-text-subtle)] mt-1") do
|
|
132
|
+
if value.respond_to?(:strftime)
|
|
133
|
+
# display_datetime_value returns HTML-safe <time> markup
|
|
134
|
+
# rendered by the timeago Stimulus controller.
|
|
135
|
+
raw safe(helpers.display_datetime_value(value))
|
|
136
|
+
else
|
|
137
|
+
plain helpers.display_name_of(value)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------
|
|
143
|
+
# Card chrome — selection, actions, show
|
|
144
|
+
# ---------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def render_actions_dropdown
|
|
147
|
+
# Cards have limited surface area, so all collection-record
|
|
148
|
+
# actions (including primary ones like Edit) live in the
|
|
149
|
+
# dropdown rather than splitting between buttons and a menu
|
|
150
|
+
# like the table view does.
|
|
151
|
+
actions = row_actions.reject { |a| a.name == :show }
|
|
152
|
+
return if actions.empty?
|
|
153
|
+
div(class: "absolute top-2 right-2 z-10") do
|
|
154
|
+
RowActionsDropdown(actions: actions, record:)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Hidden link the `row-click` controller delegates to when the
|
|
159
|
+
# user clicks anywhere on the card body. Mirrors how the show
|
|
160
|
+
# action button works in the Table view.
|
|
161
|
+
def render_show_link
|
|
162
|
+
show = resource_definition.defined_actions[:show]
|
|
163
|
+
url = route_options_to_url(show.route_options, record)
|
|
164
|
+
a(
|
|
165
|
+
href: url,
|
|
166
|
+
data: {row_click_target: "show", turbo_frame: show.turbo_frame},
|
|
167
|
+
class: "sr-only",
|
|
168
|
+
tabindex: "-1",
|
|
169
|
+
"aria-label": "Open #{header_text}"
|
|
170
|
+
) { plain "Open" }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------
|
|
174
|
+
# Helpers
|
|
175
|
+
# ---------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def header_text
|
|
178
|
+
@header_text ||= helpers.display_name_of(field_value(slots[:header]) || record)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def field_value(name)
|
|
182
|
+
return nil unless name
|
|
183
|
+
# Skip fields the user's policy doesn't permit. nil collapses
|
|
184
|
+
# the slot in render_*_slot guards above.
|
|
185
|
+
return nil if resource_fields && !resource_fields.include?(name.to_sym)
|
|
186
|
+
unless record.respond_to?(name)
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"grid_fields slot points at `:#{name}` but " \
|
|
189
|
+
"#{record.class.name} doesn't respond to it. " \
|
|
190
|
+
"Define the method on the model or remove the slot."
|
|
191
|
+
end
|
|
192
|
+
record.public_send(name)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Resolves a slot value to an image URL. Supports:
|
|
196
|
+
# - ActiveStorage attachments (`record.avatar` -> Attached::One/Many)
|
|
197
|
+
# - Shrine uploaders (`record.avatar` -> UploadedFile, responds to :url)
|
|
198
|
+
# - Plain URL strings ("https://..." or "/uploads/...")
|
|
199
|
+
def image_src_for(value)
|
|
200
|
+
return nil if value.nil?
|
|
201
|
+
if value.respond_to?(:attached?)
|
|
202
|
+
value.attached? ? helpers.url_for(value) : nil
|
|
203
|
+
elsif value.respond_to?(:url)
|
|
204
|
+
value.url
|
|
205
|
+
elsif value.is_a?(String) && value.start_with?("http", "/")
|
|
206
|
+
value
|
|
207
|
+
end
|
|
208
|
+
rescue ArgumentError, URI::InvalidURIError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def row_actions
|
|
213
|
+
@row_actions ||= resource_definition.defined_actions.values.select { |a|
|
|
214
|
+
a.collection_record_action? && a.permitted_by?(record_policy)
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def can_show?
|
|
219
|
+
resource_definition.defined_actions[:show]&.permitted_by?(record_policy)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def record_policy
|
|
223
|
+
@record_policy ||= policy_for(record:)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def card_class
|
|
227
|
+
tokens(
|
|
228
|
+
"pu-card relative overflow-hidden transition-shadow",
|
|
229
|
+
-> { can_show? } => "cursor-pointer hover:shadow-md focus-within:ring-2 focus-within:ring-primary-500"
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|