plutonium 0.50.0 → 0.52.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 +574 -0
- data/.claude/skills/plutonium-auth/SKILL.md +167 -302
- 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 +674 -0
- data/.claude/skills/plutonium-testing/SKILL.md +9 -6
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +44 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1010 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +38 -29
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -57
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +98 -462
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +93 -298
- data/docs/guides/custom-actions.md +126 -441
- data/docs/guides/customizing-ui.md +258 -0
- data/docs/guides/index.md +49 -52
- data/docs/guides/multi-tenancy.md +123 -186
- data/docs/guides/nested-resources.md +137 -396
- data/docs/guides/search-filtering.md +127 -238
- data/docs/guides/testing.md +10 -5
- data/docs/guides/theming.md +168 -405
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +112 -425
- data/docs/guides/user-profile.md +82 -241
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- 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 +229 -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 +67 -48
- 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 +368 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +400 -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 +121 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -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/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -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/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +45 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +30 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +23 -5
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/form/base.rb +23 -3
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +103 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/form/theme.rb +1 -1
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/edit.rb +1 -1
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/page/new.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +13 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/form_controller.js +5 -4
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +661 -544
- metadata +86 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -7,6 +7,7 @@ module Plutonium
|
|
|
7
7
|
# Select for choosing a resource record
|
|
8
8
|
class ResourceSelect < Phlexi::Form::Components::Select
|
|
9
9
|
include Plutonium::UI::Component::Methods
|
|
10
|
+
include Plutonium::UI::Form::Concerns::TypeaheadAttributes
|
|
10
11
|
|
|
11
12
|
# Cap on the number of records the dropdown materialises. Keeps
|
|
12
13
|
# very large association tables from rendering thousands of
|
|
@@ -23,18 +24,23 @@ module Plutonium
|
|
|
23
24
|
elsif @association_class.nil?
|
|
24
25
|
[]
|
|
25
26
|
else
|
|
26
|
-
|
|
27
|
-
relation = relation.limit(@choice_limit) if relation.respond_to?(:limit) && @choice_limit
|
|
28
|
-
if @skip_authorization
|
|
29
|
-
relation
|
|
30
|
-
else
|
|
31
|
-
authorized_resource_scope(@association_class, relation: relation)
|
|
32
|
-
end
|
|
27
|
+
authorized_relation(limit: @choice_limit)
|
|
33
28
|
end
|
|
34
29
|
build_choice_mapper(collection)
|
|
35
30
|
end
|
|
36
31
|
end
|
|
37
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
|
+
|
|
38
44
|
def build_attributes
|
|
39
45
|
# Defaults must land BEFORE super — AcceptsChoices.build_attributes
|
|
40
46
|
# consumes :value_method / :label_method off `attributes` into
|
|
@@ -48,6 +54,17 @@ module Plutonium
|
|
|
48
54
|
@skip_authorization = attributes.delete(:skip_authorization)
|
|
49
55
|
@choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
|
|
50
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)
|
|
51
68
|
end
|
|
52
69
|
|
|
53
70
|
# SGIDs include a timestamp + signature, so the SGID in the URL
|
|
@@ -69,9 +86,22 @@ module Plutonium
|
|
|
69
86
|
# string-equals a freshly generated option SGID for the same
|
|
70
87
|
# record, so the value gets silently dropped — no WHERE clause
|
|
71
88
|
# is built and the filter behaves as if it weren't applied.
|
|
72
|
-
#
|
|
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.
|
|
73
96
|
def normalize_simple_input(input_value)
|
|
74
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
|
+
|
|
75
105
|
choices.values.find { |opt| same_record?(input_value, opt) } && input_value
|
|
76
106
|
end
|
|
77
107
|
|
|
@@ -102,6 +132,30 @@ module Plutonium
|
|
|
102
132
|
def blank_option_text
|
|
103
133
|
@include_blank.is_a?(String) ? @include_blank : super
|
|
104
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
|
|
105
159
|
end
|
|
106
160
|
end
|
|
107
161
|
end
|
|
@@ -6,6 +6,7 @@ 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
|
|
9
10
|
|
|
10
11
|
DEFAULT_CHOICE_LIMIT = Plutonium::UI::Form::Components::ResourceSelect::DEFAULT_CHOICE_LIMIT
|
|
11
12
|
|
|
@@ -21,46 +22,100 @@ module Plutonium
|
|
|
21
22
|
delegate :association_reflection, to: :field
|
|
22
23
|
|
|
23
24
|
def render_add_button
|
|
24
|
-
return if @add_action == false
|
|
25
|
+
return if @add_action == false
|
|
26
|
+
|
|
27
|
+
# Two stacking levels are supported (primary + secondary modal).
|
|
28
|
+
# Hide the "+" entirely once we're already inside the secondary —
|
|
29
|
+
# there's no tertiary frame to escalate to.
|
|
30
|
+
return if in_secondary_modal?
|
|
31
|
+
|
|
32
|
+
url, turbo_frame = add_url_and_frame
|
|
33
|
+
return unless url
|
|
34
|
+
|
|
35
|
+
# When the parent form is already inside the primary modal,
|
|
36
|
+
# route the "+" to the secondary frame so the stacked dialog
|
|
37
|
+
# opens on top of the original form rather than replacing it.
|
|
38
|
+
# The crud controller mirrors this on success — closing the
|
|
39
|
+
# secondary modal and reloading the primary so the association
|
|
40
|
+
# select picks up the new record.
|
|
41
|
+
if turbo_frame == Plutonium::REMOTE_MODAL_FRAME && in_modal?
|
|
42
|
+
turbo_frame = Plutonium::REMOTE_MODAL_SECONDARY_FRAME
|
|
43
|
+
end
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
href:
|
|
45
|
+
attrs = {
|
|
46
|
+
href: url,
|
|
28
47
|
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"
|
|
29
|
-
|
|
48
|
+
}
|
|
49
|
+
attrs[:data] = {turbo_frame: turbo_frame} if turbo_frame
|
|
50
|
+
|
|
51
|
+
a(**attrs) do
|
|
30
52
|
render Phlex::TablerIcons::Plus.new(class: "w-4 h-4")
|
|
31
53
|
end
|
|
32
54
|
end
|
|
33
55
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
56
|
+
# Resolves the destination for the inline "+" button alongside
|
|
57
|
+
# the association select. We go through the target resource's
|
|
58
|
+
# `:new` action (rather than building a URL by hand) so the
|
|
59
|
+
# button inherits whatever modal/slideover frame the target
|
|
60
|
+
# resource is configured for — same path table/grid use for
|
|
61
|
+
# their own "New" button. A custom string `add_action:` skips
|
|
62
|
+
# the frame lookup since we can't infer the target's modal
|
|
63
|
+
# mode from an arbitrary URL.
|
|
64
|
+
def add_url_and_frame
|
|
65
|
+
klass = association_reflection.klass
|
|
66
|
+
|
|
67
|
+
if @add_action.is_a?(String)
|
|
68
|
+
return [with_return_to(@add_action), nil] if @skip_authorization || allowed_to?(:create?, klass)
|
|
69
|
+
return
|
|
70
|
+
end
|
|
37
71
|
|
|
38
|
-
|
|
39
|
-
|
|
72
|
+
return unless registered_resources.include?(klass)
|
|
73
|
+
action = resource_definition(klass).defined_actions[:new]
|
|
74
|
+
return unless action
|
|
75
|
+
return unless @skip_authorization || action.permitted_by?(policy_for(record: klass))
|
|
40
76
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
77
|
+
url = route_options_to_url(action.route_options, klass)
|
|
78
|
+
[with_return_to(url), action.turbo_frame]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def with_return_to(url)
|
|
82
|
+
uri = URI(url)
|
|
83
|
+
params = Rack::Utils.parse_nested_query(uri.query)
|
|
84
|
+
params["return_to"] = request.original_url
|
|
85
|
+
uri.query = params.to_query
|
|
86
|
+
uri.to_s
|
|
45
87
|
end
|
|
46
88
|
|
|
47
89
|
def choices
|
|
48
90
|
@choices ||= begin
|
|
49
|
-
collection =
|
|
50
|
-
@raw_choices
|
|
51
|
-
elsif @skip_authorization
|
|
52
|
-
choices_from_association(association_reflection.klass)
|
|
53
|
-
else
|
|
54
|
-
authorized_resource_scope(association_reflection.klass, relation: choices_from_association(association_reflection.klass))
|
|
55
|
-
end
|
|
91
|
+
collection = @raw_choices || authorized_relation
|
|
56
92
|
collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
|
|
57
93
|
build_choice_mapper(collection)
|
|
58
94
|
end
|
|
59
95
|
end
|
|
60
96
|
|
|
97
|
+
# Builds the authorized association relation. Shared by `choices`
|
|
98
|
+
# (which then applies `choice_limit`) and `normalize_simple_input`
|
|
99
|
+
# (which validates against the full set so typeahead picks beyond
|
|
100
|
+
# the rendered subset still survive submit).
|
|
101
|
+
def authorized_relation
|
|
102
|
+
klass = association_reflection.klass
|
|
103
|
+
relation = choices_from_association(klass)
|
|
104
|
+
return relation if @skip_authorization
|
|
105
|
+
authorized_resource_scope(klass, relation: relation)
|
|
106
|
+
end
|
|
107
|
+
|
|
61
108
|
def build_attributes
|
|
62
109
|
build_association_attributes
|
|
63
110
|
super
|
|
111
|
+
# Stash; the URL helper needs view_context which only exists
|
|
112
|
+
# once we're rendering.
|
|
113
|
+
@typeahead_option = attributes.delete(:typeahead)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def before_template
|
|
117
|
+
super
|
|
118
|
+
configure_typeahead_attributes!(@typeahead_option)
|
|
64
119
|
end
|
|
65
120
|
|
|
66
121
|
def build_association_attributes
|
|
@@ -79,6 +134,20 @@ module Plutonium
|
|
|
79
134
|
end
|
|
80
135
|
end
|
|
81
136
|
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Polymorphic reflections raise NameError on #klass — they
|
|
140
|
+
# have no single target class to search, so opt out.
|
|
141
|
+
def typeahead_target_class
|
|
142
|
+
return nil unless association_reflection
|
|
143
|
+
return nil if association_reflection.respond_to?(:polymorphic?) && association_reflection.polymorphic?
|
|
144
|
+
association_reflection.klass
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def typeahead_kind_and_name(_typeahead_option)
|
|
148
|
+
[:input, association_reflection.name]
|
|
149
|
+
end
|
|
150
|
+
|
|
82
151
|
def build_singluar_association_attributes
|
|
83
152
|
attributes.fetch(:input_param) { attributes[:input_param] = :"#{association_reflection.name}_sgid" }
|
|
84
153
|
end
|
|
@@ -88,9 +157,21 @@ module Plutonium
|
|
|
88
157
|
attributes[:multiple] = true
|
|
89
158
|
end
|
|
90
159
|
|
|
160
|
+
# Validates a submitted SGID against the authorized association scope
|
|
161
|
+
# (not against `choices`, which is capped at `choice_limit` and may
|
|
162
|
+
# not include records reachable via typeahead). For explicit
|
|
163
|
+
# `@raw_choices`, fall back to membership in the rendered list.
|
|
91
164
|
def normalize_simple_input(input_value)
|
|
92
|
-
|
|
93
|
-
|
|
165
|
+
sgid = SignedGlobalID.parse(input_value.presence)
|
|
166
|
+
return nil unless sgid
|
|
167
|
+
|
|
168
|
+
if @raw_choices
|
|
169
|
+
@signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
|
|
170
|
+
return @signed_global_ids.include?(sgid) ? sgid : nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
return nil unless sgid.model_class <= association_reflection.klass
|
|
174
|
+
authorized_relation.exists?(id: sgid.model_id) ? sgid : nil
|
|
94
175
|
end
|
|
95
176
|
|
|
96
177
|
def selected?(option)
|
|
@@ -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
|
|
@@ -14,7 +14,7 @@ module Plutonium
|
|
|
14
14
|
inner_wrapper: "w-full",
|
|
15
15
|
|
|
16
16
|
# Form errors
|
|
17
|
-
form_errors_wrapper: "flex items-start gap-3
|
|
17
|
+
form_errors_wrapper: "flex items-start gap-3 m-4 p-4 text-base text-danger-800 rounded-[var(--pu-radius-lg)] bg-danger-50 border border-danger-200 dark:bg-danger-950/30 dark:border-danger-800 dark:text-danger-300",
|
|
18
18
|
form_errors_message: "font-semibold",
|
|
19
19
|
form_errors_list: "mt-2 list-disc list-inside text-sm",
|
|
20
20
|
|
|
@@ -44,7 +44,7 @@ module Plutonium
|
|
|
44
44
|
query: current_query_object,
|
|
45
45
|
search_url: request.path,
|
|
46
46
|
search_value: params.dig(:q, :search) || params[:search],
|
|
47
|
-
views: resource_definition.
|
|
47
|
+
views: resource_definition.defined_index_views,
|
|
48
48
|
current_view: :grid,
|
|
49
49
|
view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
|
|
50
50
|
view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
|
|
@@ -90,13 +90,6 @@ module Plutonium
|
|
|
90
90
|
# Returns false by default; pages opt-in by overriding.
|
|
91
91
|
def aside_present? = false
|
|
92
92
|
|
|
93
|
-
# True when the page is rendered inside any turbo frame.
|
|
94
|
-
def in_frame? = current_turbo_frame.present?
|
|
95
|
-
|
|
96
|
-
# True when the page is rendered inside the remote_modal turbo frame.
|
|
97
|
-
# Used by form pages to suppress the sticky footer (modal owns its own footer).
|
|
98
|
-
def in_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
|
|
99
|
-
|
|
100
93
|
# Customization hooks
|
|
101
94
|
def render_before_header
|
|
102
95
|
end
|
|
@@ -46,11 +46,11 @@ module Plutonium
|
|
|
46
46
|
# Resolution order:
|
|
47
47
|
# 1. `?view=` URL param (so a shared link can pin a view)
|
|
48
48
|
# 2. The view-preference cookie (sticky per-resource selection)
|
|
49
|
-
# 3. The resource's `
|
|
50
|
-
# `
|
|
49
|
+
# 3. The resource's `default_index_view` (which itself defaults to
|
|
50
|
+
# `index_views.first`)
|
|
51
51
|
def selected_view
|
|
52
52
|
definition = current_definition
|
|
53
|
-
enabled = definition.
|
|
53
|
+
enabled = definition.defined_index_views
|
|
54
54
|
|
|
55
55
|
requested = params[:view]&.to_sym
|
|
56
56
|
return requested if requested && enabled.include?(requested)
|
|
@@ -58,7 +58,7 @@ module Plutonium
|
|
|
58
58
|
stored = helpers.cookies[self.class.view_cookie_name(resource_class)]&.to_sym
|
|
59
59
|
return stored if stored && enabled.include?(stored)
|
|
60
60
|
|
|
61
|
-
definition.
|
|
61
|
+
definition.default_index_view
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def page_type = :index_page
|
|
@@ -14,10 +14,7 @@ module Plutonium
|
|
|
14
14
|
def initialize(*, query_object:, search_url:, search_param: :q, search_value: nil, attributes: {}, **opts, &)
|
|
15
15
|
opts[:as] = :q
|
|
16
16
|
opts[:method] = :get
|
|
17
|
-
attributes = attributes.deep_merge(
|
|
18
|
-
id: "filter-form",
|
|
19
|
-
data: {turbo_frame: nil}
|
|
20
|
-
)
|
|
17
|
+
attributes = attributes.deep_merge(data: {turbo_frame: nil})
|
|
21
18
|
super(*, attributes:, **opts, &)
|
|
22
19
|
@query_object = query_object
|
|
23
20
|
@search_url = search_url
|
|
@@ -25,6 +22,17 @@ module Plutonium
|
|
|
25
22
|
@search_value = search_value
|
|
26
23
|
end
|
|
27
24
|
|
|
25
|
+
# The Filters slideover renders on every index page (off-screen
|
|
26
|
+
# until opened) — same DOM as any CRUD modal that might appear.
|
|
27
|
+
# Base defaults the form id to "resource-form"; without this
|
|
28
|
+
# override, document-level `turbo_stream.replace("resource-form", …)`
|
|
29
|
+
# from a CRUD submit would clobber the wrong form. Pick a distinct
|
|
30
|
+
# id so the two never collide.
|
|
31
|
+
def initialize_attributes
|
|
32
|
+
super
|
|
33
|
+
attributes[:id] = "filter-form"
|
|
34
|
+
end
|
|
35
|
+
|
|
28
36
|
def form_class
|
|
29
37
|
"flex-1 flex flex-col min-h-0"
|
|
30
38
|
end
|
|
@@ -44,7 +44,7 @@ module Plutonium
|
|
|
44
44
|
query: current_query_object,
|
|
45
45
|
search_url: current_search_url,
|
|
46
46
|
search_value: params.dig(:q, :search) || params[:search],
|
|
47
|
-
views: resource_definition.
|
|
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
50
|
view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
|
data/lib/plutonium/version.rb
CHANGED
data/lib/plutonium.rb
CHANGED
|
@@ -28,6 +28,14 @@ module Plutonium
|
|
|
28
28
|
# frame name lives in one place.
|
|
29
29
|
REMOTE_MODAL_FRAME = "remote_modal"
|
|
30
30
|
|
|
31
|
+
# Secondary modal frame, used to stack a modal on top of the primary one
|
|
32
|
+
# (e.g. clicking the inline "+" next to an association field while the
|
|
33
|
+
# parent form is itself rendered in the primary modal). The layout
|
|
34
|
+
# renders a second frame, and `in_modal?` recognises both.
|
|
35
|
+
REMOTE_MODAL_SECONDARY_FRAME = "remote_modal_secondary"
|
|
36
|
+
|
|
37
|
+
MODAL_FRAMES = [REMOTE_MODAL_FRAME, REMOTE_MODAL_SECONDARY_FRAME].freeze
|
|
38
|
+
|
|
31
39
|
# Set up Zeitwerk loader for the Plutonium gem
|
|
32
40
|
# @return [Zeitwerk::Loader] configured Zeitwerk loader instance
|
|
33
41
|
Loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false).tap do |loader|
|
data/lib/tasks/release.rake
CHANGED
|
@@ -102,7 +102,11 @@ namespace :release do
|
|
|
102
102
|
desc "Build front-end assets"
|
|
103
103
|
task :build_frontend do
|
|
104
104
|
puts "Building front-end assets..."
|
|
105
|
-
|
|
105
|
+
# in: File::NULL — yarn 4 puts the terminal in raw mode for its
|
|
106
|
+
# progress UI and doesn't always restore it on exit. Without this,
|
|
107
|
+
# subsequent `$stdin.gets` prompts read one keystroke at a time and
|
|
108
|
+
# never see a newline, so Enter never terminates the line.
|
|
109
|
+
system("yarn build", in: File::NULL) || abort("Front-end build failed")
|
|
106
110
|
puts "✓ Built front-end assets"
|
|
107
111
|
end
|
|
108
112
|
|
|
@@ -160,6 +164,14 @@ namespace :release do
|
|
|
160
164
|
exit 1
|
|
161
165
|
end
|
|
162
166
|
|
|
167
|
+
# Snapshot the terminal mode up front. yarn 4 and git-cliff both put
|
|
168
|
+
# the TTY in raw mode for progress UIs and don't always restore it,
|
|
169
|
+
# which breaks every subsequent `$stdin.gets` (Enter arrives as a
|
|
170
|
+
# bare \r and gets() never returns). Restore the snapshot before each
|
|
171
|
+
# prompt so the user can actually answer.
|
|
172
|
+
tty_state = `stty -g 2>/dev/null`.strip
|
|
173
|
+
restore_tty = -> { system("stty #{tty_state} 2>/dev/null") if tty_state != "" }
|
|
174
|
+
|
|
163
175
|
puts "Starting release workflow for v#{version}..."
|
|
164
176
|
|
|
165
177
|
# Check npm authentication early, login if needed
|
|
@@ -179,6 +191,7 @@ namespace :release do
|
|
|
179
191
|
current_branch = `git branch --show-current`.strip
|
|
180
192
|
unless current_branch == "main" || current_branch == "master"
|
|
181
193
|
puts "Warning: You're not on main/master branch (current: #{current_branch})"
|
|
194
|
+
restore_tty.call
|
|
182
195
|
print "Continue anyway? [y/N] "
|
|
183
196
|
exit 1 unless $stdin.gets.strip.downcase == "y"
|
|
184
197
|
end
|
|
@@ -187,6 +200,7 @@ namespace :release do
|
|
|
187
200
|
Rake::Task["release:prepare"].invoke(version)
|
|
188
201
|
|
|
189
202
|
# Confirm before proceeding
|
|
203
|
+
restore_tty.call
|
|
190
204
|
puts "\nReady to commit, tag, and publish?"
|
|
191
205
|
print "Continue? [y/N] "
|
|
192
206
|
exit 0 unless $stdin.gets.strip.downcase == "y"
|
data/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radioactive-labs/plutonium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.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",
|
|
@@ -25,27 +25,30 @@
|
|
|
25
25
|
"@uppy/dashboard": "^4.1.3",
|
|
26
26
|
"@uppy/image-editor": "^3.2.1",
|
|
27
27
|
"@uppy/xhr-upload": "^4.2.3",
|
|
28
|
-
"dompurify": "^3.
|
|
28
|
+
"dompurify": "^3.4.3",
|
|
29
29
|
"lodash.debounce": "^4.0.8",
|
|
30
30
|
"marked": "^15.0.3"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
+
"@tabler/icons-vue": "^3.44.0",
|
|
33
34
|
"@tailwindcss/forms": "^0.5.10",
|
|
34
|
-
"@tailwindcss/postcss": "^4.0
|
|
35
|
+
"@tailwindcss/postcss": "^4.3.0",
|
|
35
36
|
"@tailwindcss/typography": "^0.5.16",
|
|
37
|
+
"asciinema-player": "^3.15.1",
|
|
36
38
|
"chokidar-cli": "^3.0.0",
|
|
37
39
|
"concurrently": "^8.2.2",
|
|
38
40
|
"cssnano": "^7.0.2",
|
|
39
|
-
"esbuild": "^0.
|
|
41
|
+
"esbuild": "^0.28.0",
|
|
40
42
|
"esbuild-plugin-manifest": "^1.0.3",
|
|
41
43
|
"flowbite-typography": "^1.0.5",
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"postcss
|
|
44
|
+
"medium-zoom": "^1.1.0",
|
|
45
|
+
"mermaid": "^11.15.0",
|
|
46
|
+
"postcss": "^8.5.14",
|
|
47
|
+
"postcss-cli": "^11.0.1",
|
|
45
48
|
"postcss-hash": "^3.0.0",
|
|
46
|
-
"postcss-import": "^16.1.
|
|
47
|
-
"tailwindcss": "^4.0
|
|
48
|
-
"vitepress": "^1.4
|
|
49
|
+
"postcss-import": "^16.1.1",
|
|
50
|
+
"tailwindcss": "^4.3.0",
|
|
51
|
+
"vitepress": "^1.6.4",
|
|
49
52
|
"vitepress-plugin-mermaid": "^2.0.17"
|
|
50
53
|
},
|
|
51
54
|
"scripts": {
|
data/src/css/slim_select.css
CHANGED
|
@@ -151,6 +151,10 @@
|
|
|
151
151
|
@apply absolute flex h-auto flex-col w-auto max-h-72 border transition-all duration-200 opacity-0 z-[10000] overflow-hidden;
|
|
152
152
|
background-color: var(--pu-surface);
|
|
153
153
|
border-color: var(--pu-border);
|
|
154
|
+
/* Default text color for everything inside the panel — covers the
|
|
155
|
+
"No Results" text and any other slim-select chrome that doesn't
|
|
156
|
+
declare its own color rule. Specific rules below still win. */
|
|
157
|
+
color: var(--pu-text);
|
|
154
158
|
box-shadow: var(--pu-shadow-md);
|
|
155
159
|
transform: scaleY(0);
|
|
156
160
|
transform-origin: top;
|