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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# Adds the `metadata` DSL — a list of field names rendered in the
|
|
6
|
+
# show page's right-side panel as label/value rows. Opt-in: when no
|
|
7
|
+
# `metadata` call is made, the show page stays full-width with no
|
|
8
|
+
# aside.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# class PostDefinition < Plutonium::Definition::Base
|
|
12
|
+
# metadata :created_at, :updated_at, :author, :state
|
|
13
|
+
# end
|
|
14
|
+
module Metadata
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
class_attribute :defined_metadata_fields, default: [], instance_accessor: false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
# Declares the fields rendered in the show page metadata panel.
|
|
23
|
+
# Each name is looked up in `defined_fields` for display config
|
|
24
|
+
# (label/format), so a field can have custom formatting in the
|
|
25
|
+
# main show body and the panel without redeclaring.
|
|
26
|
+
#
|
|
27
|
+
# @param names [Array<Symbol>]
|
|
28
|
+
def metadata(*names)
|
|
29
|
+
self.defined_metadata_fields = names.flatten.map(&:to_sym)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# class_attribute is declared with instance_accessor: false; expose
|
|
34
|
+
# an instance reader that delegates so callers with a definition
|
|
35
|
+
# instance (e.g. `current_definition`) can ask without poking the
|
|
36
|
+
# class directly. Mirrors Definition::Views.
|
|
37
|
+
def defined_metadata_fields = self.class.defined_metadata_fields
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -5,8 +5,19 @@ 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
|
-
turbo_frame_tag(
|
|
20
|
+
turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME, &)
|
|
10
21
|
end
|
|
11
22
|
end
|
|
12
23
|
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)
|
data/lib/plutonium/query/base.rb
CHANGED
|
@@ -24,6 +24,14 @@ module Plutonium
|
|
|
24
24
|
def apply(scope, **params)
|
|
25
25
|
raise NotImplementedError, "#{self.class}#apply(scope, **params)"
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# Human-readable rendering of a single filter value for the active
|
|
29
|
+
# filter pill row. Defaults to `value.to_s`. Subclasses
|
|
30
|
+
# (Filters::Association, Filters::Boolean) override to translate
|
|
31
|
+
# raw param values (SGIDs, "true"/"false") into recognisable text.
|
|
32
|
+
def humanize_value(value)
|
|
33
|
+
value.to_s
|
|
34
|
+
end
|
|
27
35
|
end
|
|
28
36
|
end
|
|
29
37
|
end
|
|
@@ -16,7 +16,7 @@ module Plutonium
|
|
|
16
16
|
# filter :user, with: :association, class_name: User, scope: ->(s) { s.active }
|
|
17
17
|
#
|
|
18
18
|
class Association < Filter
|
|
19
|
-
def initialize(class_name: nil, resource_class: nil, scope: nil, multiple:
|
|
19
|
+
def initialize(class_name: nil, resource_class: nil, scope: nil, multiple: true, **)
|
|
20
20
|
super(**)
|
|
21
21
|
@class_name = class_name
|
|
22
22
|
@resource_class = resource_class
|
|
@@ -24,15 +24,21 @@ module Plutonium
|
|
|
24
24
|
@multiple = multiple
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def humanize_value(value)
|
|
28
|
+
return "" if value.blank?
|
|
29
|
+
ids = decode_ids(value)
|
|
30
|
+
return "" if ids.empty?
|
|
31
|
+
records = association_class.where(id: ids)
|
|
32
|
+
records.map { |r| r.respond_to?(:to_label) ? r.to_label : r.to_s }.join(", ")
|
|
33
|
+
rescue
|
|
34
|
+
Array(value).reject(&:blank?).join(", ")
|
|
35
|
+
end
|
|
36
|
+
|
|
27
37
|
def apply(scope, value:)
|
|
28
38
|
return scope if value.blank?
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
scope.where(foreign_key => value.reject(&:blank?))
|
|
33
|
-
else
|
|
34
|
-
scope.where(foreign_key => value)
|
|
35
|
-
end
|
|
39
|
+
ids = decode_ids(value)
|
|
40
|
+
return scope if ids.empty?
|
|
41
|
+
scope.where("#{key}_id": ids)
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
def customize_inputs
|
|
@@ -45,6 +51,22 @@ module Plutonium
|
|
|
45
51
|
|
|
46
52
|
private
|
|
47
53
|
|
|
54
|
+
# Accepts either an SGID (the new default sent by ResourceSelect)
|
|
55
|
+
# or a raw id (legacy URLs). Returns the underlying record ids.
|
|
56
|
+
def decode_ids(value)
|
|
57
|
+
Array(value).reject(&:blank?).filter_map { |v| decode_id(v) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def decode_id(value)
|
|
61
|
+
gid = SignedGlobalID.parse(value)
|
|
62
|
+
return gid.model_id if gid
|
|
63
|
+
value
|
|
64
|
+
rescue
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
48
70
|
def association_class
|
|
49
71
|
@association_class ||= resolve_class_name || detect_class_from_reflection || infer_class_from_key
|
|
50
72
|
end
|
|
@@ -16,6 +16,11 @@ module Plutonium
|
|
|
16
16
|
@false_label = false_label
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def humanize_value(value)
|
|
20
|
+
return "" if value.blank?
|
|
21
|
+
ActiveModel::Type::Boolean.new.cast(value) ? @true_label : @false_label
|
|
22
|
+
end
|
|
23
|
+
|
|
19
24
|
def apply(scope, value:)
|
|
20
25
|
return scope if value.blank?
|
|
21
26
|
|
|
@@ -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 }
|
|
@@ -58,7 +58,7 @@ module Plutonium
|
|
|
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,
|
|
@@ -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
|
|
@@ -5,7 +5,7 @@ module Plutonium
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
included do
|
|
8
|
-
helper_method :build_form, :build_detail, :build_collection
|
|
8
|
+
helper_method :build_form, :build_detail, :build_collection, :build_grid_collection
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
private
|
|
@@ -82,8 +82,17 @@ module Plutonium
|
|
|
82
82
|
current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
def build_grid_collection
|
|
86
|
+
current_definition.grid_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
|
|
87
|
+
end
|
|
88
|
+
|
|
85
89
|
def build_detail
|
|
86
|
-
current_definition.detail_class.new(
|
|
90
|
+
current_definition.detail_class.new(
|
|
91
|
+
resource_record!,
|
|
92
|
+
resource_fields: presentable_attributes,
|
|
93
|
+
resource_associations: permitted_associations,
|
|
94
|
+
resource_definition: current_definition
|
|
95
|
+
)
|
|
87
96
|
end
|
|
88
97
|
|
|
89
98
|
def build_form(record = resource_record!, action: action_name, form_action: nil, **)
|
|
@@ -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
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
module Plutonium
|
|
2
2
|
module Resource
|
|
3
3
|
class Definition < Plutonium::Definition::Base
|
|
4
|
+
class_attribute :modal_mode, default: :slideover, instance_accessor: false
|
|
5
|
+
|
|
6
|
+
VALID_MODAL_MODES = [:centered, :slideover, false].freeze
|
|
7
|
+
|
|
8
|
+
# Sets how :new / :edit actions render.
|
|
9
|
+
# - :slideover (default) — slide-in panel from the right
|
|
10
|
+
# - :centered — centered dialog
|
|
11
|
+
# - false — no modal; new/edit are full standalone pages
|
|
12
|
+
def self.modal(mode)
|
|
13
|
+
unless VALID_MODAL_MODES.include?(mode)
|
|
14
|
+
raise ArgumentError, "modal must be one of #{VALID_MODAL_MODES.inspect}, got #{mode.inspect}"
|
|
15
|
+
end
|
|
16
|
+
self.modal_mode = mode
|
|
17
|
+
configure_crud_modal_targets!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Re-derives the default :new / :edit actions so their turbo_frame
|
|
21
|
+
# matches the current `modal_mode`. Called when `.modal` is set
|
|
22
|
+
# and once at Resource::Definition load (so the default
|
|
23
|
+
# :slideover state propagates to the action records). Subclasses
|
|
24
|
+
# inherit those records via DefineableProps#inherited (deep_dup);
|
|
25
|
+
# calling `.modal` on a subclass re-runs this method locally.
|
|
26
|
+
def self.configure_crud_modal_targets!
|
|
27
|
+
target = (modal_mode == false) ? nil : Plutonium::REMOTE_MODAL_FRAME
|
|
28
|
+
[:new, :edit].each do |name|
|
|
29
|
+
action = defined_actions[name]
|
|
30
|
+
next unless action
|
|
31
|
+
next if action.turbo_frame == target
|
|
32
|
+
defined_actions[name] = action.with(turbo_frame: target)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def modal
|
|
37
|
+
self.class.modal_mode
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Apply the default modal target ("remote_modal") to :new / :edit
|
|
41
|
+
# so resources that never call `.modal` still get the slideover
|
|
42
|
+
# behavior. Subclasses inherit the configured actions via
|
|
43
|
+
# DefineableProps' deep_dup; calling `.modal` on a subclass
|
|
44
|
+
# re-runs the configuration locally.
|
|
45
|
+
configure_crud_modal_targets!
|
|
4
46
|
end
|
|
5
47
|
end
|
|
6
48
|
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.
|
|
@@ -63,6 +63,7 @@ module Plutonium
|
|
|
63
63
|
# Builds a URL with the given options for search and sorting.
|
|
64
64
|
#
|
|
65
65
|
# @param options [Hash] The options for building the URL.
|
|
66
|
+
# @option options [Boolean] :replace When true, clears all existing sorts before applying the new one
|
|
66
67
|
# @return [String] The constructed URL with query parameters.
|
|
67
68
|
def build_url(**options)
|
|
68
69
|
q = {}
|
|
@@ -74,11 +75,19 @@ module Plutonium
|
|
|
74
75
|
selected_scope_filter
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
if options.delete(:replace)
|
|
79
|
+
q[:sort_directions] = {}
|
|
80
|
+
q[:sort_fields] = []
|
|
81
|
+
else
|
|
82
|
+
q[:sort_directions] = selected_sort_directions.dup
|
|
83
|
+
q[:sort_fields] = selected_sort_fields.dup
|
|
84
|
+
end
|
|
79
85
|
handle_sort_options!(q, options)
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
filter_keys = filter_definitions.keys.map(&:to_sym)
|
|
88
|
+
filter_overrides = options.slice(*filter_keys).stringify_keys
|
|
89
|
+
q.merge! params.with_indifferent_access.slice(*filter_definitions.keys)
|
|
90
|
+
q.merge!(filter_overrides)
|
|
82
91
|
compacted = deep_compact({q: q})
|
|
83
92
|
|
|
84
93
|
# Preserve explicit "All" selection (scope: nil in options means show all)
|
|
@@ -119,18 +128,67 @@ module Plutonium
|
|
|
119
128
|
|
|
120
129
|
def sort_definitions = @sort_definitions ||= {}.with_indifferent_access
|
|
121
130
|
|
|
131
|
+
# Returns an array of hashes describing each currently active filter.
|
|
132
|
+
# Each hash has: name, label, value_label, clear_url
|
|
133
|
+
def active_filter_descriptions
|
|
134
|
+
filter_definitions.filter_map do |name, filter|
|
|
135
|
+
name = name.to_sym
|
|
136
|
+
filter_params = params[name]
|
|
137
|
+
next unless filter_params.present?
|
|
138
|
+
|
|
139
|
+
value_label = case filter_params
|
|
140
|
+
when Hash, ActionController::Parameters
|
|
141
|
+
entries = filter_params.to_h.reject { |_, v| v.blank? }
|
|
142
|
+
next if entries.empty?
|
|
143
|
+
# Single-input filters defer to the filter's `humanize_value`
|
|
144
|
+
# (e.g. Association resolves ids to labels, Boolean translates
|
|
145
|
+
# "true" -> "Yes"). Multi-input filters keep input-name
|
|
146
|
+
# qualifiers (e.g. "from 2024, to 2025").
|
|
147
|
+
if entries.size == 1
|
|
148
|
+
humanized = filter.humanize_value(entries.values.first)
|
|
149
|
+
next if humanized.blank?
|
|
150
|
+
humanized
|
|
151
|
+
else
|
|
152
|
+
entries.map { |k, v| "#{k.to_s.humanize.downcase} #{v}" }.join(", ")
|
|
153
|
+
end
|
|
154
|
+
when Array
|
|
155
|
+
entries = filter_params.reject(&:blank?)
|
|
156
|
+
next if entries.empty?
|
|
157
|
+
humanized = filter.humanize_value(entries)
|
|
158
|
+
next if humanized.blank?
|
|
159
|
+
humanized
|
|
160
|
+
else
|
|
161
|
+
next if filter_params.to_s.blank?
|
|
162
|
+
humanized = filter.humanize_value(filter_params)
|
|
163
|
+
next if humanized.blank?
|
|
164
|
+
humanized
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
name: name,
|
|
169
|
+
label: name.to_s.humanize,
|
|
170
|
+
value_label: value_label,
|
|
171
|
+
clear_url: build_url(name => nil)
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
122
176
|
# Provides sorting parameters for the given field name.
|
|
123
177
|
#
|
|
124
178
|
# @param name [Symbol, String] The name of the field to sort.
|
|
125
|
-
# @return [Hash, nil] The sorting parameters including URL and
|
|
179
|
+
# @return [Hash, nil] The sorting parameters including URL, multi_url, direction, position and multi flag.
|
|
126
180
|
def sort_params_for(name)
|
|
127
181
|
return unless sort_definitions[name]
|
|
128
182
|
|
|
183
|
+
multi = selected_sort_fields.size > 1 && selected_sort_fields.include?(name.to_s)
|
|
184
|
+
|
|
129
185
|
{
|
|
130
|
-
url: build_url(sort: name),
|
|
186
|
+
url: build_url(sort: name, replace: true),
|
|
187
|
+
multi_url: build_url(sort: name),
|
|
131
188
|
reset_url: build_url(sort: name, reset: true),
|
|
132
189
|
position: selected_sort_fields.index(name.to_s),
|
|
133
|
-
direction: selected_sort_directions[name]
|
|
190
|
+
direction: selected_sort_directions[name],
|
|
191
|
+
multi: multi
|
|
134
192
|
}
|
|
135
193
|
end
|
|
136
194
|
|
|
@@ -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
|
|
@@ -33,8 +33,8 @@ module Plutonium
|
|
|
33
33
|
klass.defined_fields.each_key do |field_name|
|
|
34
34
|
next if field_name == :id
|
|
35
35
|
assert resource_class.column_names.include?(field_name.to_s) ||
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
resource_class.method_defined?(field_name) ||
|
|
37
|
+
resource_class.reflect_on_association(field_name),
|
|
38
38
|
"Field :#{field_name} declared in #{klass} but not defined on #{resource_class}"
|
|
39
39
|
end
|
|
40
40
|
end
|
|
@@ -24,10 +24,11 @@ module Plutonium
|
|
|
24
24
|
|
|
25
25
|
DROPDOWN_DEFAULT_COLOR = "text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
|
|
26
26
|
|
|
27
|
-
def initialize(action, url:, variant: :default)
|
|
27
|
+
def initialize(action, url:, variant: :default, data: {})
|
|
28
28
|
@action = action
|
|
29
29
|
@url = url
|
|
30
30
|
@variant = variant
|
|
31
|
+
@extra_data = data
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def view_template
|
|
@@ -49,7 +50,7 @@ module Plutonium
|
|
|
49
50
|
link_to(
|
|
50
51
|
url_with_return_to,
|
|
51
52
|
class: button_classes,
|
|
52
|
-
data: {turbo_frame: @action.turbo_frame}
|
|
53
|
+
data: {turbo_frame: @action.turbo_frame}.merge(@extra_data)
|
|
53
54
|
) do
|
|
54
55
|
render_button_content
|
|
55
56
|
end
|
|
@@ -61,6 +62,7 @@ module Plutonium
|
|
|
61
62
|
method: @action.route_options.method,
|
|
62
63
|
name: :return_to, value: return_to_url,
|
|
63
64
|
class: "inline-block",
|
|
65
|
+
data: @extra_data,
|
|
64
66
|
form: {
|
|
65
67
|
data: {
|
|
66
68
|
turbo: @action.turbo,
|