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,17 +7,15 @@ module Plutonium
|
|
|
7
7
|
#
|
|
8
8
|
# @example Enable both views, default to Grid
|
|
9
9
|
# class UserDefinition < Plutonium::Resource::Definition
|
|
10
|
-
# views :table, :grid
|
|
11
|
-
# default_view :grid
|
|
12
|
-
#
|
|
13
10
|
# grid_fields(
|
|
14
11
|
# image: :avatar,
|
|
15
12
|
# header: :name,
|
|
16
13
|
# subheader: :email,
|
|
17
14
|
# meta: [:role, :status]
|
|
18
15
|
# )
|
|
16
|
+
# default_index_view :grid
|
|
19
17
|
# end
|
|
20
|
-
module
|
|
18
|
+
module IndexViews
|
|
21
19
|
extend ActiveSupport::Concern
|
|
22
20
|
|
|
23
21
|
KNOWN_VIEWS = %i[table grid].freeze
|
|
@@ -25,8 +23,8 @@ module Plutonium
|
|
|
25
23
|
GRID_LAYOUTS = %i[compact media].freeze
|
|
26
24
|
|
|
27
25
|
included do
|
|
28
|
-
class_attribute :
|
|
29
|
-
class_attribute :
|
|
26
|
+
class_attribute :defined_index_views, default: [:table], instance_accessor: false
|
|
27
|
+
class_attribute :defined_default_index_view, default: nil, instance_accessor: false
|
|
30
28
|
class_attribute :defined_grid_fields, default: {}, instance_accessor: false
|
|
31
29
|
class_attribute :defined_grid_layout, default: :compact, instance_accessor: false
|
|
32
30
|
class_attribute :defined_grid_columns, default: nil, instance_accessor: false
|
|
@@ -34,37 +32,40 @@ module Plutonium
|
|
|
34
32
|
|
|
35
33
|
class_methods do
|
|
36
34
|
# Declares the index views this resource supports.
|
|
35
|
+
# Usually unnecessary — declaring `grid_fields` auto-enables :grid
|
|
36
|
+
# alongside the default :table. Use `index_views` only to disable
|
|
37
|
+
# one (e.g. `index_views :grid` to drop the table view).
|
|
37
38
|
# @param list [Array<Symbol>] one or more of {KNOWN_VIEWS}
|
|
38
|
-
def
|
|
39
|
+
def index_views(*list)
|
|
39
40
|
list = list.flatten.map(&:to_sym)
|
|
40
41
|
invalid = list - KNOWN_VIEWS
|
|
41
|
-
raise ArgumentError, "Unknown
|
|
42
|
-
self.
|
|
42
|
+
raise ArgumentError, "Unknown index_views: #{invalid.inspect}. Valid: #{KNOWN_VIEWS}" if invalid.any?
|
|
43
|
+
self.defined_index_views = list.empty? ? [:table] : list
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
# Declares the default index view. Must be one of {.
|
|
46
|
+
# Declares the default index view. Must be one of {.index_views}.
|
|
46
47
|
# Falls back to the first declared view if unset.
|
|
47
|
-
def
|
|
48
|
+
def default_index_view(name = nil)
|
|
48
49
|
if name.nil?
|
|
49
|
-
|
|
50
|
+
defined_default_index_view || defined_index_views.first
|
|
50
51
|
else
|
|
51
52
|
name = name.to_sym
|
|
52
|
-
unless
|
|
53
|
-
raise ArgumentError, "
|
|
53
|
+
unless defined_index_views.include?(name)
|
|
54
|
+
raise ArgumentError, "default_index_view #{name.inspect} not in index_views #{defined_index_views.inspect}"
|
|
54
55
|
end
|
|
55
|
-
self.
|
|
56
|
+
self.defined_default_index_view = name
|
|
56
57
|
end
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
# Maps grid slots to fields. Each slot is optional. Implicitly
|
|
60
|
-
# adds `:grid` to {.
|
|
61
|
-
# view simply by declaring its slots.
|
|
61
|
+
# adds `:grid` to {.index_views} so a resource can opt into the
|
|
62
|
+
# Grid view simply by declaring its slots.
|
|
62
63
|
# @param slots [Hash{Symbol => Symbol, Array<Symbol>}]
|
|
63
64
|
def grid_fields(**slots)
|
|
64
65
|
invalid = slots.keys - GRID_SLOTS
|
|
65
66
|
raise ArgumentError, "Unknown grid slots: #{invalid.inspect}. Valid: #{GRID_SLOTS}" if invalid.any?
|
|
66
67
|
self.defined_grid_fields = slots
|
|
67
|
-
self.
|
|
68
|
+
self.defined_index_views = defined_index_views + [:grid] unless defined_index_views.include?(:grid)
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
# Layout shape for grid cards. :compact (default) places the image
|
|
@@ -84,8 +85,8 @@ module Plutonium
|
|
|
84
85
|
end
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
def
|
|
88
|
-
def
|
|
88
|
+
def defined_index_views = self.class.defined_index_views
|
|
89
|
+
def default_index_view = self.class.default_index_view
|
|
89
90
|
def defined_grid_fields = self.class.defined_grid_fields
|
|
90
91
|
def defined_grid_layout = self.class.defined_grid_layout
|
|
91
92
|
def defined_grid_columns = self.class.defined_grid_columns
|
|
@@ -5,9 +5,39 @@ module Plutonium
|
|
|
5
5
|
request.headers["Turbo-Frame"]
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
+
# True when the request is rendered inside any turbo frame.
|
|
9
|
+
def in_frame? = current_turbo_frame.present?
|
|
10
|
+
|
|
11
|
+
# True when the request is rendered inside either modal frame
|
|
12
|
+
# (primary or secondary).
|
|
13
|
+
def in_modal? = Plutonium::MODAL_FRAMES.include?(current_turbo_frame)
|
|
14
|
+
|
|
15
|
+
# True when the request is rendered inside the secondary (stacked)
|
|
16
|
+
# modal frame specifically.
|
|
17
|
+
def in_secondary_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_SECONDARY_FRAME
|
|
18
|
+
|
|
8
19
|
def remote_modal_frame_tag(&)
|
|
9
20
|
turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME, &)
|
|
10
21
|
end
|
|
22
|
+
|
|
23
|
+
# Returns a turbo-frame-scoped element id. Two identically-named forms
|
|
24
|
+
# can be on the page simultaneously (e.g. a primary modal opens a
|
|
25
|
+
# secondary modal, each rendering an `id="resource-form"`). When the
|
|
26
|
+
# server later replies with `turbo_stream.replace("resource-form", ...)`,
|
|
27
|
+
# Turbo would pick the FIRST element matching the id — which is rarely
|
|
28
|
+
# the one the user actually submitted. Append a frame suffix so each
|
|
29
|
+
# frame's form has a unique id and the controller can target precisely.
|
|
30
|
+
#
|
|
31
|
+
# @param base [String, Symbol] the base id
|
|
32
|
+
# @return [String] the scoped id (no suffix outside any modal frame)
|
|
33
|
+
def turbo_scoped_dom_id(base)
|
|
34
|
+
base = base.to_s
|
|
35
|
+
case current_turbo_frame
|
|
36
|
+
when Plutonium::REMOTE_MODAL_FRAME then "#{base}-primary"
|
|
37
|
+
when Plutonium::REMOTE_MODAL_SECONDARY_FRAME then "#{base}-secondary"
|
|
38
|
+
else base
|
|
39
|
+
end
|
|
40
|
+
end
|
|
11
41
|
end
|
|
12
42
|
end
|
|
13
43
|
end
|
|
@@ -9,6 +9,20 @@ module Plutonium
|
|
|
9
9
|
end
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
# Closes the <dialog> inside the targeted frame and empties the
|
|
13
|
+
# frame. Used to dismiss a stacked modal without affecting the
|
|
14
|
+
# rest of the page.
|
|
15
|
+
def turbo_stream_close_frame(frame_id)
|
|
16
|
+
turbo_stream_action_tag :close_frame, target: frame_id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Reloads the targeted frame from its current src. Used to refresh
|
|
20
|
+
# the primary modal after a secondary-modal action mutates data
|
|
21
|
+
# the primary depends on.
|
|
22
|
+
def turbo_stream_reload_frame(frame_id)
|
|
23
|
+
turbo_stream_action_tag :reload_frame, target: frame_id
|
|
24
|
+
end
|
|
25
|
+
|
|
12
26
|
private
|
|
13
27
|
|
|
14
28
|
def turbo_stream_redirect_same_page?(url)
|
|
@@ -15,6 +15,7 @@ module Plutonium
|
|
|
15
15
|
include Plutonium::Resource::Controllers::Queryable
|
|
16
16
|
include Plutonium::Resource::Controllers::CrudActions
|
|
17
17
|
include Plutonium::Resource::Controllers::InteractiveActions
|
|
18
|
+
include Plutonium::Resource::Controllers::Typeahead
|
|
18
19
|
|
|
19
20
|
included do
|
|
20
21
|
after_action { response.headers.merge!(@pagy.headers_hash) if @pagy }
|
|
@@ -53,12 +53,12 @@ module Plutonium
|
|
|
53
53
|
|
|
54
54
|
respond_to do |format|
|
|
55
55
|
if params[:pre_submit]
|
|
56
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))) }
|
|
56
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))) }
|
|
57
57
|
format.html { render :new, status: :unprocessable_content }
|
|
58
58
|
elsif resource_record!.save
|
|
59
59
|
format.turbo_stream do
|
|
60
60
|
flash.notice = "#{resource_class.model_name.human} was successfully created."
|
|
61
|
-
render turbo_stream:
|
|
61
|
+
render turbo_stream: stacked_modal_create_streams
|
|
62
62
|
end
|
|
63
63
|
format.html do
|
|
64
64
|
redirect_to redirect_url_after_submit,
|
|
@@ -71,7 +71,7 @@ module Plutonium
|
|
|
71
71
|
location: redirect_url_after_submit
|
|
72
72
|
end
|
|
73
73
|
else
|
|
74
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))), status: :unprocessable_content }
|
|
74
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))), status: :unprocessable_content }
|
|
75
75
|
format.html { render :new, status: :unprocessable_content }
|
|
76
76
|
format.any do
|
|
77
77
|
@errors = resource_record!.errors
|
|
@@ -100,7 +100,7 @@ module Plutonium
|
|
|
100
100
|
|
|
101
101
|
respond_to do |format|
|
|
102
102
|
if params[:pre_submit]
|
|
103
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))) }
|
|
103
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))) }
|
|
104
104
|
format.html { render :edit, status: :unprocessable_content }
|
|
105
105
|
elsif resource_record!.save
|
|
106
106
|
format.turbo_stream do
|
|
@@ -116,7 +116,7 @@ module Plutonium
|
|
|
116
116
|
render :show, status: :ok, location: redirect_url_after_submit
|
|
117
117
|
end
|
|
118
118
|
else
|
|
119
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))), status: :unprocessable_content }
|
|
119
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))), status: :unprocessable_content }
|
|
120
120
|
format.html { render :edit, status: :unprocessable_content }
|
|
121
121
|
format.any do
|
|
122
122
|
@errors = resource_record!.errors
|
|
@@ -164,6 +164,24 @@ module Plutonium
|
|
|
164
164
|
|
|
165
165
|
private
|
|
166
166
|
|
|
167
|
+
# When the create came in through the secondary (stacked) modal
|
|
168
|
+
# frame — i.e. the user clicked "+" next to an association field
|
|
169
|
+
# while the parent form was already in a modal — we don't want to
|
|
170
|
+
# navigate anywhere. Close the secondary dialog and reload the
|
|
171
|
+
# primary modal frame so the just-created record appears in the
|
|
172
|
+
# association select. Outside that case fall back to the normal
|
|
173
|
+
# post-submit redirect.
|
|
174
|
+
def stacked_modal_create_streams
|
|
175
|
+
if helpers.in_secondary_modal?
|
|
176
|
+
[
|
|
177
|
+
helpers.turbo_stream_close_frame(Plutonium::REMOTE_MODAL_SECONDARY_FRAME),
|
|
178
|
+
helpers.turbo_stream_reload_frame(Plutonium::REMOTE_MODAL_FRAME)
|
|
179
|
+
]
|
|
180
|
+
else
|
|
181
|
+
helpers.turbo_stream_redirect(redirect_url_after_submit)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
167
185
|
def redirect_url_after_submit
|
|
168
186
|
if (return_to = url_from(params[:return_to]))
|
|
169
187
|
return return_to
|
|
@@ -38,7 +38,7 @@ module Plutonium
|
|
|
38
38
|
|
|
39
39
|
if params[:pre_submit]
|
|
40
40
|
respond_to do |format|
|
|
41
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
|
|
41
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
|
|
42
42
|
format.html { render :interactive_record_action, formats: [:html], status: :unprocessable_content }
|
|
43
43
|
end
|
|
44
44
|
return
|
|
@@ -87,7 +87,7 @@ module Plutonium
|
|
|
87
87
|
|
|
88
88
|
if params[:pre_submit]
|
|
89
89
|
respond_to do |format|
|
|
90
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
|
|
90
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
|
|
91
91
|
format.html { render :interactive_resource_action, status: :unprocessable_content }
|
|
92
92
|
end
|
|
93
93
|
return
|
|
@@ -134,7 +134,7 @@ module Plutonium
|
|
|
134
134
|
|
|
135
135
|
if params[:pre_submit]
|
|
136
136
|
respond_to do |format|
|
|
137
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
|
|
137
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
|
|
138
138
|
format.html { render :interactive_bulk_action, formats: [:html], status: :unprocessable_content }
|
|
139
139
|
end
|
|
140
140
|
return
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Resource
|
|
5
|
+
module Controllers
|
|
6
|
+
# Backend dispatch for typeahead/autocomplete queries against
|
|
7
|
+
# resource form inputs and index filter inputs. Auto-mounted on
|
|
8
|
+
# every Plutonium resource via the `interactive_resource_actions`
|
|
9
|
+
# routing concern (see Plutonium::Routing::MapperExtensions).
|
|
10
|
+
#
|
|
11
|
+
# The controller resolves what to query directly from the input
|
|
12
|
+
# definition + the model's association reflection — no widget
|
|
13
|
+
# indirection. Two source kinds are supported:
|
|
14
|
+
#
|
|
15
|
+
# 1. Static `choices: [...]` — case-insensitive substring filter.
|
|
16
|
+
# 2. Association — either `association_class:` set on the input,
|
|
17
|
+
# or inferred from `resource_class.reflect_on_association(name)`.
|
|
18
|
+
#
|
|
19
|
+
# Association queries route through the associated resource's
|
|
20
|
+
# `policy.relation_scope` so users only see records they can read.
|
|
21
|
+
module Typeahead
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
TYPEAHEAD_LIMIT = 50
|
|
25
|
+
|
|
26
|
+
# Priority list tried when the input doesn't tell us which
|
|
27
|
+
# column carries its label. Aligns with what `to_label` usually
|
|
28
|
+
# wraps. Used only as a last resort.
|
|
29
|
+
FALLBACK_SEARCH_COLUMNS = %w[name title label slug display_name email].freeze
|
|
30
|
+
|
|
31
|
+
# Returns the column to LIKE against when no `search` block is
|
|
32
|
+
# defined. Used by both the server (to build the WHERE clause)
|
|
33
|
+
# and the input component (to decide whether to attach the
|
|
34
|
+
# typeahead URL).
|
|
35
|
+
#
|
|
36
|
+
# Resolution order:
|
|
37
|
+
# 1. The input's `label_method` if it names a real column (so
|
|
38
|
+
# `input :user, label_method: :email` just works).
|
|
39
|
+
# 2. The first match from FALLBACK_SEARCH_COLUMNS.
|
|
40
|
+
# 3. nil — no usable column, server returns unfiltered.
|
|
41
|
+
#
|
|
42
|
+
# The fallback is fine for moderate tables but uses a leading-
|
|
43
|
+
# wildcard LIKE which can't be served by a b-tree index. For
|
|
44
|
+
# large tables, declare a `search` block that uses a trigram or
|
|
45
|
+
# full-text index instead.
|
|
46
|
+
def self.searchable_column_for(klass, label_method: nil)
|
|
47
|
+
cols = klass.column_names
|
|
48
|
+
if label_method && cols.include?(label_method.to_s)
|
|
49
|
+
return label_method.to_s
|
|
50
|
+
end
|
|
51
|
+
FALLBACK_SEARCH_COLUMNS.find { |c| cols.include?(c) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Escapes the SQL LIKE wildcards `%` and `_` (plus the escape
|
|
55
|
+
# char itself) so a user searching for "100%" doesn't match
|
|
56
|
+
# everything. The literal `!` is used as the ESCAPE character —
|
|
57
|
+
# unambiguous across sqlite/postgres/mysql, no backslash-quoting
|
|
58
|
+
# surprises.
|
|
59
|
+
LIKE_ESCAPE_CHAR = "!"
|
|
60
|
+
def self.escape_like(value)
|
|
61
|
+
value.to_s.gsub(/[!%_]/) { |c| "#{LIKE_ESCAPE_CHAR}#{c}" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
included do
|
|
65
|
+
before_action :authorize_typeahead!, only: %i[typeahead_input typeahead_filter]
|
|
66
|
+
# Read-only JSON; row-level auth is enforced inline through
|
|
67
|
+
# authorized_resource_scope, so the after_action verifier is
|
|
68
|
+
# redundant.
|
|
69
|
+
skip_verify_current_authorized_scope only: %i[typeahead_input typeahead_filter]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# GET /<resource>/typeahead/input/:name?q=...
|
|
73
|
+
def typeahead_input
|
|
74
|
+
field_name = params[:name].to_sym
|
|
75
|
+
defn = current_definition.defined_inputs[field_name]
|
|
76
|
+
# Inputs are often inferred from the model (no explicit
|
|
77
|
+
# `input :foo` in the definition). Accept the request when the
|
|
78
|
+
# field name maps to a real association even without an entry.
|
|
79
|
+
unless defn || resource_class.reflect_on_association(field_name)
|
|
80
|
+
return head(:not_found)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
render_typeahead_response(defn || {}, field_name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# GET /<resource>/typeahead/filter/:name?q=...
|
|
87
|
+
def typeahead_filter
|
|
88
|
+
filter = current_query_object.filter_definitions[params[:name].to_sym]
|
|
89
|
+
return head(:not_found) unless filter
|
|
90
|
+
|
|
91
|
+
defn = filter.defined_inputs[:value]
|
|
92
|
+
return head(:not_found) unless defn
|
|
93
|
+
|
|
94
|
+
render_typeahead_response(defn, params[:name].to_sym)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def render_typeahead_response(defn, field_name)
|
|
100
|
+
options = defn[:options] || {}
|
|
101
|
+
query = params[:q].to_s
|
|
102
|
+
candidates = collect_typeahead_candidates(options, field_name, query)
|
|
103
|
+
|
|
104
|
+
if candidates.nil?
|
|
105
|
+
return render(json: {error: "input has no typeahead source"}, status: :bad_request)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
has_more = candidates.length > TYPEAHEAD_LIMIT
|
|
109
|
+
results = candidates.first(TYPEAHEAD_LIMIT).map { |row| serialize_typeahead_row(row) }
|
|
110
|
+
render json: {results: results, has_more: has_more}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns the candidate list, or nil if the input has neither
|
|
114
|
+
# static choices nor a resolvable association class.
|
|
115
|
+
def collect_typeahead_candidates(options, field_name, query)
|
|
116
|
+
if options[:choices]
|
|
117
|
+
filter_static_choices(options[:choices], query)
|
|
118
|
+
elsif (klass = typeahead_association_class(options, field_name))
|
|
119
|
+
filter_association(klass, query, options)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def typeahead_association_class(options, field_name)
|
|
124
|
+
options[:association_class] ||
|
|
125
|
+
resource_class.reflect_on_association(field_name)&.klass
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def filter_static_choices(choices, query)
|
|
129
|
+
return choices if query.blank?
|
|
130
|
+
q = query.downcase
|
|
131
|
+
choices.select { |label, _| label.to_s.downcase.include?(q) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Routes through the associated resource's policy.relation_scope
|
|
135
|
+
# so typeahead never surfaces records the user can't read, then
|
|
136
|
+
# narrows via the associated resource definition's `search` block
|
|
137
|
+
# when present. Without a search block, fall back to a case-
|
|
138
|
+
# insensitive LIKE on the first column in FALLBACK_SEARCH_COLUMNS
|
|
139
|
+
# that exists on the model (so a resource with a `name` column
|
|
140
|
+
# gets useful typeahead without declaring `search`). If neither
|
|
141
|
+
# search block nor fallback column is available, the relation is
|
|
142
|
+
# returned unfiltered (capped).
|
|
143
|
+
def filter_association(klass, query, options)
|
|
144
|
+
relation = options[:skip_authorization] ? klass.all : authorized_resource_scope(klass)
|
|
145
|
+
if query.present?
|
|
146
|
+
if (search_block = associated_definition_search_block(klass))
|
|
147
|
+
relation = search_block.call(relation, query)
|
|
148
|
+
elsif (col = Typeahead.searchable_column_for(klass, label_method: options[:label_method]))
|
|
149
|
+
quoted = klass.connection.quote_column_name(col)
|
|
150
|
+
pattern = "%#{Typeahead.escape_like(query.downcase)}%"
|
|
151
|
+
relation = relation.where("LOWER(#{quoted}) LIKE ? ESCAPE '#{Typeahead::LIKE_ESCAPE_CHAR}'", pattern)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
relation.limit(Typeahead::TYPEAHEAD_LIMIT + 1).to_a
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Resolves the associated resource's `search` block, if declared.
|
|
158
|
+
# Goes through `resource_definition` so portal/package namespacing
|
|
159
|
+
# is honored (same fallback chain as the rest of the controller).
|
|
160
|
+
def associated_definition_search_block(klass)
|
|
161
|
+
resource_definition(klass).class._search_definition
|
|
162
|
+
rescue NameError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def serialize_typeahead_row(row)
|
|
167
|
+
if row.is_a?(Array)
|
|
168
|
+
{value: row[1].to_s, label: row[0].to_s}
|
|
169
|
+
else
|
|
170
|
+
{value: row.to_signed_global_id.to_s, label: row.to_label}
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def authorize_typeahead!
|
|
175
|
+
authorize_current! resource_class, to: :typeahead?
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -179,6 +179,13 @@ module Plutonium
|
|
|
179
179
|
index?
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# Checks if typeahead/autocomplete queries are permitted.
|
|
183
|
+
#
|
|
184
|
+
# @return [Boolean] Delegates to index?.
|
|
185
|
+
def typeahead?
|
|
186
|
+
index?
|
|
187
|
+
end
|
|
188
|
+
|
|
182
189
|
# Core attributes
|
|
183
190
|
|
|
184
191
|
# Returns the permitted attributes for the create action.
|
|
@@ -41,6 +41,7 @@ module Plutonium
|
|
|
41
41
|
concern :interactive_resource_actions do
|
|
42
42
|
define_member_interactive_actions
|
|
43
43
|
define_collection_interactive_actions
|
|
44
|
+
define_collection_typeahead_actions
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
@@ -161,6 +162,20 @@ module Plutonium
|
|
|
161
162
|
as: :commit_interactive_resource_action
|
|
162
163
|
end
|
|
163
164
|
end
|
|
165
|
+
|
|
166
|
+
# Defines collection-level typeahead actions for resource form inputs
|
|
167
|
+
# and index filter inputs. Auto-mounted alongside record_actions and
|
|
168
|
+
# bulk_actions on every Plutonium resource.
|
|
169
|
+
#
|
|
170
|
+
# @return [void]
|
|
171
|
+
def define_collection_typeahead_actions
|
|
172
|
+
collection do
|
|
173
|
+
get "typeahead/input/:name", action: :typeahead_input,
|
|
174
|
+
as: :typeahead_input
|
|
175
|
+
get "typeahead/filter/:name", action: :typeahead_filter,
|
|
176
|
+
as: :typeahead_filter
|
|
177
|
+
end
|
|
178
|
+
end
|
|
164
179
|
end
|
|
165
180
|
end
|
|
166
181
|
end
|
|
@@ -40,10 +40,15 @@ 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?,
|
|
51
|
+
:turbo_scoped_dom_id,
|
|
47
52
|
:current_interactive_action,
|
|
48
53
|
:current_engine,
|
|
49
54
|
:policy_for,
|
|
@@ -66,6 +66,10 @@ module Plutonium
|
|
|
66
66
|
create_component(Components::KeyValueStore, :key_value_store, **, &)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
def json_input_tag(**, &)
|
|
70
|
+
create_component(Components::Json, :json, **, &)
|
|
71
|
+
end
|
|
72
|
+
|
|
69
73
|
def resource_select_tag(**attributes, &)
|
|
70
74
|
attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
|
|
71
75
|
# class!: "" clears the underlying <select>'s themed classes
|
|
@@ -107,8 +111,8 @@ module Plutonium
|
|
|
107
111
|
alias_method :date_tag, :flatpickr_tag
|
|
108
112
|
alias_method :time_tag, :flatpickr_tag
|
|
109
113
|
alias_method :rich_text_tag, :markdown_tag
|
|
110
|
-
alias_method :json_tag, :
|
|
111
|
-
alias_method :jsonb_tag, :
|
|
114
|
+
alias_method :json_tag, :json_input_tag
|
|
115
|
+
alias_method :jsonb_tag, :json_input_tag
|
|
112
116
|
alias_method :hstore_tag, :key_value_store_tag
|
|
113
117
|
alias_method :key_value_tag, :key_value_store_tag
|
|
114
118
|
alias_method :association_tag, :secure_association_tag
|
|
@@ -145,9 +149,25 @@ module Plutonium
|
|
|
145
149
|
def initialize_attributes
|
|
146
150
|
super
|
|
147
151
|
|
|
148
|
-
attributes[:id] ||=
|
|
152
|
+
attributes[:id] ||= "resource-form"
|
|
149
153
|
attributes["data-controller"] = "form"
|
|
150
154
|
end
|
|
155
|
+
|
|
156
|
+
# Scope the form id to the current turbo frame at render time (we
|
|
157
|
+
# can't do this in `initialize_attributes` — Phlex hasn't started
|
|
158
|
+
# rendering yet, so `view_context` and the request headers aren't
|
|
159
|
+
# accessible). Primary and secondary modals can each host a form
|
|
160
|
+
# without colliding on document-level turbo-stream `replace target=`
|
|
161
|
+
# lookups. See Helpers::TurboHelper#turbo_scoped_dom_id.
|
|
162
|
+
#
|
|
163
|
+
# Also force-replace the id (Phlexi's `mix` would otherwise prepend
|
|
164
|
+
# `@namespace.dom_id`, producing space-separated ids like
|
|
165
|
+
# "q filter-form" which break document.getElementById lookups).
|
|
166
|
+
def form_attributes
|
|
167
|
+
attrs = super
|
|
168
|
+
attrs[:id] = turbo_scoped_dom_id(attributes[:id]) if attributes[:id]
|
|
169
|
+
attrs
|
|
170
|
+
end
|
|
151
171
|
end
|
|
152
172
|
end
|
|
153
173
|
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
|