plutonium 0.49.1 → 0.50.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-definition/SKILL.md +87 -2
- data/.claude/skills/plutonium-installation/SKILL.md +6 -0
- data/.claude/skills/plutonium-views/SKILL.md +59 -0
- data/CHANGELOG.md +12 -0
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.js +369 -25
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +45 -45
- 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/reference/definition/actions.md +14 -1
- data/docs/reference/definition/index.md +58 -0
- data/docs/reference/views/index.md +43 -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/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/core/update/update_generator.rb +20 -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/metadata.rb +40 -0
- data/lib/plutonium/definition/views.rb +94 -0
- data/lib/plutonium/helpers/turbo_helper.rb +1 -1
- 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/controllers/presentable.rb +11 -2
- data/lib/plutonium/resource/definition.rb +42 -0
- data/lib/plutonium/resource/query_object.rb +64 -6
- 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/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 +29 -1
- data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
- data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
- data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
- data/lib/plutonium/ui/form/resource.rb +48 -9
- 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 +37 -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 +25 -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 +6 -0
- data/package.json +1 -1
- data/src/css/components.css +304 -131
- 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/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
- metadata +31 -3
|
@@ -8,7 +8,8 @@ module Plutonium
|
|
|
8
8
|
super.merge({
|
|
9
9
|
base: "",
|
|
10
10
|
value_wrapper: "max-h-[300px] overflow-y-auto",
|
|
11
|
-
fields_wrapper: "
|
|
11
|
+
fields_wrapper: "pu-card",
|
|
12
|
+
fields_inner: "pu-card-body grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
|
|
12
13
|
|
|
13
14
|
# Labels and descriptions
|
|
14
15
|
label: "text-sm font-semibold uppercase tracking-wide text-[var(--pu-text-muted)] mb-2",
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
module Plutonium
|
|
2
2
|
module UI
|
|
3
3
|
module DynaFrame
|
|
4
|
+
# Conditionally wraps its content in a turbo-frame matching the inbound
|
|
5
|
+
# request's `Turbo-Frame` header. In frame mode adds the flash partial
|
|
6
|
+
# so toast/alert messages still surface inside frames; in non-frame
|
|
7
|
+
# mode renders the block as-is.
|
|
4
8
|
class Content < Plutonium::UI::Component::Base
|
|
5
9
|
include Phlex::Rails::Helpers::TurboFrameTag
|
|
6
10
|
|
|
7
|
-
def
|
|
8
|
-
@content = content
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def view_template
|
|
11
|
+
def view_template(&block)
|
|
12
12
|
if current_turbo_frame.present?
|
|
13
|
-
# Frame request: render only the turbo-frame with content
|
|
14
13
|
turbo_frame_tag(current_turbo_frame) do
|
|
15
14
|
render partial("flash")
|
|
16
|
-
|
|
15
|
+
yield if block_given?
|
|
17
16
|
end
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
yield(self)
|
|
17
|
+
elsif block_given?
|
|
18
|
+
yield
|
|
21
19
|
end
|
|
22
20
|
end
|
|
23
|
-
|
|
24
|
-
def render_content
|
|
25
|
-
@content&.call
|
|
26
|
-
end
|
|
27
21
|
end
|
|
28
22
|
end
|
|
29
23
|
end
|
|
@@ -10,6 +10,28 @@ module Plutonium
|
|
|
10
10
|
include Phlexi::Field::Common::Tokens
|
|
11
11
|
include Plutonium::UI::Form::Options::InferredTypes
|
|
12
12
|
|
|
13
|
+
# Consume `:as` here so it doesn't land in Phlexi's `@options` —
|
|
14
|
+
# `:as` is a Plutonium-internal concept (it picks the tag method),
|
|
15
|
+
# not a Phlexi field option.
|
|
16
|
+
def initialize(*args, as: nil, **kwargs, &block)
|
|
17
|
+
@as = as
|
|
18
|
+
super(*args, **kwargs, &block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :as
|
|
22
|
+
|
|
23
|
+
def hidden?
|
|
24
|
+
as.to_s == "hidden"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Hidden fields (`form.field(name, as: :hidden)`) skip the label /
|
|
28
|
+
# hint / error chrome and render inside a `<div hidden>` so they're
|
|
29
|
+
# also excluded from CSS Grid / Flex layout.
|
|
30
|
+
def wrapped(**, &)
|
|
31
|
+
return Plutonium::UI::Form::Components::HiddenWrapper.new(self, &) if hidden?
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
13
35
|
def textarea_tag(**attributes, &)
|
|
14
36
|
attributes[:data_controller] = tokens(attributes[:data_controller], "textarea-autogrow")
|
|
15
37
|
super
|
|
@@ -45,7 +67,13 @@ module Plutonium
|
|
|
45
67
|
end
|
|
46
68
|
|
|
47
69
|
def resource_select_tag(**attributes, &)
|
|
48
|
-
|
|
70
|
+
attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
|
|
71
|
+
# class!: "" clears the underlying <select>'s themed classes
|
|
72
|
+
# (pu-input etc.) — the visible element is slim-select's
|
|
73
|
+
# generated .ss-main, so leaving Tailwind input chrome on the
|
|
74
|
+
# native select can leak into chip layout (e.g. forcing
|
|
75
|
+
# flex-direction: column or w-full on multi-mode chips).
|
|
76
|
+
create_component(Components::ResourceSelect, :select, class!: "", **attributes, &)
|
|
49
77
|
end
|
|
50
78
|
|
|
51
79
|
def secure_association_tag(**attributes, &)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# Wrapper for fields configured as `as: :hidden`. Emits a hidden div
|
|
8
|
+
# containing only the input — no label, no hint, no error chrome.
|
|
9
|
+
# `hidden: true` (HTML5) sets `display: none` so the wrapper is
|
|
10
|
+
# excluded from CSS Grid / Flex layout, not just visually hidden.
|
|
11
|
+
class HiddenWrapper < Phlexi::Form::Components::Base
|
|
12
|
+
def view_template(&block)
|
|
13
|
+
div(hidden: true) do
|
|
14
|
+
yield(field) if block
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# No id needed for a layout-suppressed wrapper.
|
|
19
|
+
def build_attributes
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -6,18 +6,96 @@ module Plutonium
|
|
|
6
6
|
module Components
|
|
7
7
|
# Select for choosing a resource record
|
|
8
8
|
class ResourceSelect < Phlexi::Form::Components::Select
|
|
9
|
+
include Plutonium::UI::Component::Methods
|
|
10
|
+
|
|
11
|
+
# Cap on the number of records the dropdown materialises. Keeps
|
|
12
|
+
# very large association tables from rendering thousands of
|
|
13
|
+
# options into the page; consumers needing more should pair this
|
|
14
|
+
# with a typeahead control later.
|
|
15
|
+
DEFAULT_CHOICE_LIMIT = 100
|
|
16
|
+
|
|
9
17
|
protected
|
|
10
18
|
|
|
11
19
|
def choices
|
|
12
20
|
@choices ||= begin
|
|
13
|
-
collection = @raw_choices
|
|
21
|
+
collection = if @raw_choices
|
|
22
|
+
@raw_choices
|
|
23
|
+
elsif @association_class.nil?
|
|
24
|
+
[]
|
|
25
|
+
else
|
|
26
|
+
relation = @association_class.all
|
|
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
|
|
33
|
+
end
|
|
14
34
|
build_choice_mapper(collection)
|
|
15
35
|
end
|
|
16
36
|
end
|
|
17
37
|
|
|
18
38
|
def build_attributes
|
|
39
|
+
# Defaults must land BEFORE super — AcceptsChoices.build_attributes
|
|
40
|
+
# consumes :value_method / :label_method off `attributes` into
|
|
41
|
+
# its own ivars, so anything we set after super has no effect.
|
|
42
|
+
attributes[:value_method] ||= :to_signed_global_id
|
|
43
|
+
attributes[:label_method] ||= :to_label
|
|
44
|
+
|
|
19
45
|
super
|
|
46
|
+
|
|
20
47
|
@association_class = attributes.delete(:association_class)
|
|
48
|
+
@skip_authorization = attributes.delete(:skip_authorization)
|
|
49
|
+
@choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
|
|
50
|
+
attributes.delete(:choice_limit)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# SGIDs include a timestamp + signature, so the SGID in the URL
|
|
54
|
+
# (generated when the user submitted) won't string-equal the
|
|
55
|
+
# SGID we just generated for the same record. Compare by the
|
|
56
|
+
# decoded model id instead, falling back to raw string equality
|
|
57
|
+
# for non-SGID values (legacy URLs / explicit raw choices).
|
|
58
|
+
def selected?(option)
|
|
59
|
+
if attributes[:multiple]
|
|
60
|
+
Array(field.value).any? { |v| same_record?(v, option) }
|
|
61
|
+
else
|
|
62
|
+
same_record?(field.value, option)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# AcceptsChoices.normalize_simple_input rejects any submitted
|
|
67
|
+
# value that doesn't string-match an option's value. With SGIDs
|
|
68
|
+
# the URL value (signed/timestamped at submit time) never
|
|
69
|
+
# string-equals a freshly generated option SGID for the same
|
|
70
|
+
# record, so the value gets silently dropped — no WHERE clause
|
|
71
|
+
# is built and the filter behaves as if it weren't applied.
|
|
72
|
+
# Match by decoded model id so the input survives.
|
|
73
|
+
def normalize_simple_input(input_value)
|
|
74
|
+
return nil if input_value.blank?
|
|
75
|
+
choices.values.find { |opt| same_record?(input_value, opt) } && input_value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Two values point at the same record when both decode to the
|
|
79
|
+
# same SGID (class + id). For explicit non-SGID `@raw_choices`,
|
|
80
|
+
# both sides are plain strings and string-equality is the only
|
|
81
|
+
# sensible answer. Mixed-format (one SGID, one raw) returns
|
|
82
|
+
# false — no cross-format guessing.
|
|
83
|
+
def same_record?(a, b)
|
|
84
|
+
return false if a.blank? || b.blank?
|
|
85
|
+
|
|
86
|
+
a_pair = decode_class_and_id(a)
|
|
87
|
+
b_pair = decode_class_and_id(b)
|
|
88
|
+
return a_pair == b_pair if a_pair && b_pair
|
|
89
|
+
return false if a_pair || b_pair
|
|
90
|
+
|
|
91
|
+
a.to_s == b.to_s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def decode_class_and_id(value)
|
|
95
|
+
gid = SignedGlobalID.parse(value)
|
|
96
|
+
gid && [gid.model_class.name, gid.model_id]
|
|
97
|
+
rescue
|
|
98
|
+
nil
|
|
21
99
|
end
|
|
22
100
|
|
|
23
101
|
# Use include_blank string as blank option text (Phlexi default uses placeholder)
|
|
@@ -7,6 +7,8 @@ module Plutonium
|
|
|
7
7
|
class SecureAssociation < Phlexi::Form::Components::AssociationBase
|
|
8
8
|
include Plutonium::UI::Component::Methods
|
|
9
9
|
|
|
10
|
+
DEFAULT_CHOICE_LIMIT = Plutonium::UI::Form::Components::ResourceSelect::DEFAULT_CHOICE_LIMIT
|
|
11
|
+
|
|
10
12
|
def view_template
|
|
11
13
|
div(class: "flex items-center space-x-1") do
|
|
12
14
|
super
|
|
@@ -23,9 +25,9 @@ module Plutonium
|
|
|
23
25
|
|
|
24
26
|
a(
|
|
25
27
|
href: add_url,
|
|
26
|
-
class: "bg-[var(--pu-surface-alt)] hover:bg-[var(--pu-border)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)]
|
|
28
|
+
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"
|
|
27
29
|
) do
|
|
28
|
-
render Phlex::TablerIcons::Plus.new(class: "w-
|
|
30
|
+
render Phlex::TablerIcons::Plus.new(class: "w-4 h-4")
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
|
|
@@ -51,6 +53,7 @@ module Plutonium
|
|
|
51
53
|
else
|
|
52
54
|
authorized_resource_scope(association_reflection.klass, relation: choices_from_association(association_reflection.klass))
|
|
53
55
|
end
|
|
56
|
+
collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
|
|
54
57
|
build_choice_mapper(collection)
|
|
55
58
|
end
|
|
56
59
|
end
|
|
@@ -63,6 +66,8 @@ module Plutonium
|
|
|
63
66
|
def build_association_attributes
|
|
64
67
|
@skip_authorization = attributes.delete(:skip_authorization)
|
|
65
68
|
@add_action = attributes.delete(:add_action)
|
|
69
|
+
@choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
|
|
70
|
+
attributes.delete(:choice_limit)
|
|
66
71
|
|
|
67
72
|
attributes.fetch(:value_method) { attributes[:value_method] = :to_signed_global_id }
|
|
68
73
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
class StickyFooter < Plutonium::UI::Component::Base
|
|
8
|
+
def view_template(&block)
|
|
9
|
+
div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
|
|
10
|
+
"h-14 bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
|
|
11
|
+
"px-6 flex items-center justify-end gap-2", &block)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -18,10 +18,23 @@ module Plutonium
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def form_template
|
|
21
|
-
|
|
21
|
+
if in_modal?
|
|
22
|
+
# In modal: form is the flex container that fills the modal
|
|
23
|
+
# body. Fields region scrolls; action strip sits flush at the
|
|
24
|
+
# bottom edge of the modal.
|
|
25
|
+
div(class: "flex-1 min-h-0 overflow-y-auto px-6 py-5") do
|
|
26
|
+
render_fields
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
render_fields
|
|
30
|
+
end
|
|
22
31
|
render_actions
|
|
23
32
|
end
|
|
24
33
|
|
|
34
|
+
def form_class
|
|
35
|
+
in_modal? ? "flex-1 flex flex-col min-h-0" : super
|
|
36
|
+
end
|
|
37
|
+
|
|
25
38
|
private
|
|
26
39
|
|
|
27
40
|
def render_fields
|
|
@@ -33,18 +46,43 @@ module Plutonium
|
|
|
33
46
|
end
|
|
34
47
|
|
|
35
48
|
def render_actions
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
# capture-url controller sets this element's value to
|
|
50
|
+
# window.location.href on connect, so URL fragments (#tab-id)
|
|
51
|
+
# survive the redirect after submit (the server never sees them).
|
|
52
|
+
input name: "return_to",
|
|
53
|
+
value: request.params[:return_to] || request.original_url,
|
|
54
|
+
type: :hidden,
|
|
55
|
+
hidden: true,
|
|
56
|
+
data: {controller: "capture-url"}
|
|
57
|
+
|
|
58
|
+
if in_modal?
|
|
59
|
+
div(class: "shrink-0 px-6 py-3 " \
|
|
60
|
+
"bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
|
|
61
|
+
"flex items-center justify-end gap-2") do
|
|
62
|
+
render_submit_and_continue_button if show_submit_and_continue?
|
|
63
|
+
render submit_button
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
render Plutonium::UI::Form::Components::StickyFooter.new do
|
|
67
|
+
render_submit_and_continue_button if show_submit_and_continue?
|
|
68
|
+
render submit_button
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
40
72
|
|
|
41
|
-
|
|
42
|
-
|
|
73
|
+
def in_modal?
|
|
74
|
+
current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
|
|
43
75
|
end
|
|
44
76
|
|
|
45
77
|
def show_submit_and_continue?
|
|
46
78
|
return false unless object.respond_to?(:new_record?)
|
|
47
79
|
|
|
80
|
+
# Continue / add-another lands on the form's standalone URL —
|
|
81
|
+
# which breaks the experience when the form is inside a frame
|
|
82
|
+
# (modal or association tab) since the redirect can't keep the
|
|
83
|
+
# user in that frame context.
|
|
84
|
+
return false if current_turbo_frame.present?
|
|
85
|
+
|
|
48
86
|
# Check explicit configuration first
|
|
49
87
|
configured = resource_definition.submit_and_continue
|
|
50
88
|
return configured unless configured.nil?
|
|
@@ -60,7 +98,7 @@ module Plutonium
|
|
|
60
98
|
type: :submit,
|
|
61
99
|
name: "return_to",
|
|
62
100
|
value: request.url,
|
|
63
|
-
class: "
|
|
101
|
+
class: "pu-btn pu-btn-md pu-btn-outline"
|
|
64
102
|
) { label }
|
|
65
103
|
end
|
|
66
104
|
|
|
@@ -116,7 +154,8 @@ module Plutonium
|
|
|
116
154
|
end
|
|
117
155
|
end
|
|
118
156
|
|
|
119
|
-
|
|
157
|
+
# Keep `:as` so the Builder can detect hidden fields via `options[:as]`.
|
|
158
|
+
field_options = field_options.except(:condition)
|
|
120
159
|
|
|
121
160
|
condition = input_options[:condition] || field_options[:condition]
|
|
122
161
|
conditionally_hidden = condition && !instance_exec(&condition)
|
|
@@ -10,12 +10,14 @@ module Plutonium
|
|
|
10
10
|
|
|
11
11
|
def view_template
|
|
12
12
|
button(
|
|
13
|
+
type: "button",
|
|
13
14
|
title: @label,
|
|
15
|
+
aria: {label: @label},
|
|
14
16
|
style: "display: none",
|
|
15
|
-
class: "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors",
|
|
17
|
+
class: "inline-flex items-center justify-center w-7 h-7 rounded text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors",
|
|
16
18
|
**@attributes
|
|
17
19
|
) {
|
|
18
|
-
render @icon.new(class: "w-
|
|
20
|
+
render @icon.new(class: "w-4 h-4")
|
|
19
21
|
}
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -31,11 +33,12 @@ module Plutonium
|
|
|
31
33
|
def view_template
|
|
32
34
|
a(
|
|
33
35
|
title: @label,
|
|
34
|
-
|
|
36
|
+
aria: {label: @label},
|
|
35
37
|
href: @href,
|
|
38
|
+
class: "inline-flex items-center justify-center w-7 h-7 rounded text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors",
|
|
36
39
|
**@attributes
|
|
37
40
|
) {
|
|
38
|
-
render @icon.new(class: "w-
|
|
41
|
+
render @icon.new(class: "w-4 h-4")
|
|
39
42
|
}
|
|
40
43
|
end
|
|
41
44
|
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Grid
|
|
6
|
+
# Renders a single record as a card built from semantic slots
|
|
7
|
+
# (image / header / subheader / body / meta / footer) declared via
|
|
8
|
+
# `grid_fields` on the resource definition. Each slot is optional;
|
|
9
|
+
# `header` falls back to `record.to_label` when undeclared.
|
|
10
|
+
class Card < Plutonium::UI::Component::Base
|
|
11
|
+
attr_reader :record, :resource_definition, :resource_fields
|
|
12
|
+
|
|
13
|
+
def initialize(record, resource_definition:, resource_fields: nil)
|
|
14
|
+
@record = record
|
|
15
|
+
@resource_definition = resource_definition
|
|
16
|
+
@resource_fields = resource_fields
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def view_template
|
|
20
|
+
article(
|
|
21
|
+
class: card_class,
|
|
22
|
+
data: {controller: "row-click", action: "click->row-click#click"}
|
|
23
|
+
) do
|
|
24
|
+
render_show_link if can_show?
|
|
25
|
+
render_actions_dropdown
|
|
26
|
+
case resource_definition.defined_grid_layout
|
|
27
|
+
when :media then render_media_layout
|
|
28
|
+
else render_compact_layout
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def slots = resource_definition.defined_grid_fields
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------
|
|
38
|
+
# Layout shells
|
|
39
|
+
# ---------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def render_compact_layout
|
|
42
|
+
div(class: "flex items-start gap-3 p-4") do
|
|
43
|
+
render_image_slot(size: :sm) if slots[:image]
|
|
44
|
+
div(class: "min-w-0 flex-1 flex flex-col gap-1") do
|
|
45
|
+
render_header_slot
|
|
46
|
+
render_subheader_slot if slots[:subheader]
|
|
47
|
+
render_body_slot if slots[:body]
|
|
48
|
+
render_meta_slot if slots[:meta]
|
|
49
|
+
render_footer_slot if footer_field
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_media_layout
|
|
55
|
+
render_image_slot(size: :cover) if slots[:image]
|
|
56
|
+
div(class: "p-4 flex flex-col gap-1") do
|
|
57
|
+
render_header_slot
|
|
58
|
+
render_subheader_slot if slots[:subheader]
|
|
59
|
+
render_body_slot if slots[:body]
|
|
60
|
+
render_meta_slot if slots[:meta]
|
|
61
|
+
render_footer_slot if footer_field
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Footer falls back to `:created_at` when the slot is unset and
|
|
66
|
+
# the record has a created_at column. Gives cards a sensible
|
|
67
|
+
# second line without forcing every grid_fields call to repeat it.
|
|
68
|
+
def footer_field
|
|
69
|
+
slots[:footer] || (record.respond_to?(:created_at) ? :created_at : nil)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------
|
|
73
|
+
# Slot renderers
|
|
74
|
+
# ---------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def render_image_slot(size:)
|
|
77
|
+
value = field_value(slots[:image])
|
|
78
|
+
return unless value
|
|
79
|
+
src = image_src_for(value)
|
|
80
|
+
return unless src
|
|
81
|
+
|
|
82
|
+
if size == :cover
|
|
83
|
+
div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
|
|
84
|
+
img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
img(
|
|
88
|
+
src: src,
|
|
89
|
+
alt: header_text.to_s,
|
|
90
|
+
class: "w-12 h-12 rounded-full object-cover bg-[var(--pu-surface-alt)] shrink-0"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_header_slot
|
|
96
|
+
h3(class: "text-sm font-semibold text-[var(--pu-text)] truncate") do
|
|
97
|
+
plain header_text
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def render_subheader_slot
|
|
102
|
+
value = field_value(slots[:subheader])
|
|
103
|
+
return if value.blank?
|
|
104
|
+
p(class: "text-xs text-[var(--pu-text-muted)] truncate") { plain helpers.display_name_of(value) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def render_body_slot
|
|
108
|
+
value = field_value(slots[:body])
|
|
109
|
+
return if value.blank?
|
|
110
|
+
p(class: "text-sm text-[var(--pu-text)] line-clamp-3") { plain helpers.display_name_of(value) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_meta_slot
|
|
114
|
+
fields = Array(slots[:meta])
|
|
115
|
+
values = fields.map { |f| field_value(f) }.reject(&:blank?)
|
|
116
|
+
return if values.empty?
|
|
117
|
+
|
|
118
|
+
div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
|
|
119
|
+
values.each do |v|
|
|
120
|
+
span(class: "inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium " \
|
|
121
|
+
"bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)]") do
|
|
122
|
+
plain helpers.display_name_of(v)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def render_footer_slot
|
|
129
|
+
value = field_value(footer_field)
|
|
130
|
+
return if value.blank?
|
|
131
|
+
p(class: "text-xs text-[var(--pu-text-subtle)] mt-1") do
|
|
132
|
+
if value.respond_to?(:strftime)
|
|
133
|
+
# display_datetime_value returns HTML-safe <time> markup
|
|
134
|
+
# rendered by the timeago Stimulus controller.
|
|
135
|
+
raw safe(helpers.display_datetime_value(value))
|
|
136
|
+
else
|
|
137
|
+
plain helpers.display_name_of(value)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------
|
|
143
|
+
# Card chrome — selection, actions, show
|
|
144
|
+
# ---------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def render_actions_dropdown
|
|
147
|
+
# Cards have limited surface area, so all collection-record
|
|
148
|
+
# actions (including primary ones like Edit) live in the
|
|
149
|
+
# dropdown rather than splitting between buttons and a menu
|
|
150
|
+
# like the table view does.
|
|
151
|
+
actions = row_actions.reject { |a| a.name == :show }
|
|
152
|
+
return if actions.empty?
|
|
153
|
+
div(class: "absolute top-2 right-2 z-10") do
|
|
154
|
+
RowActionsDropdown(actions: actions, record:)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Hidden link the `row-click` controller delegates to when the
|
|
159
|
+
# user clicks anywhere on the card body. Mirrors how the show
|
|
160
|
+
# action button works in the Table view.
|
|
161
|
+
def render_show_link
|
|
162
|
+
show = resource_definition.defined_actions[:show]
|
|
163
|
+
url = route_options_to_url(show.route_options, record)
|
|
164
|
+
a(
|
|
165
|
+
href: url,
|
|
166
|
+
data: {row_click_target: "show", turbo_frame: show.turbo_frame},
|
|
167
|
+
class: "sr-only",
|
|
168
|
+
tabindex: "-1",
|
|
169
|
+
"aria-label": "Open #{header_text}"
|
|
170
|
+
) { plain "Open" }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------
|
|
174
|
+
# Helpers
|
|
175
|
+
# ---------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def header_text
|
|
178
|
+
@header_text ||= helpers.display_name_of(field_value(slots[:header]) || record)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def field_value(name)
|
|
182
|
+
return nil unless name
|
|
183
|
+
# Skip fields the user's policy doesn't permit. nil collapses
|
|
184
|
+
# the slot in render_*_slot guards above.
|
|
185
|
+
return nil if resource_fields && !resource_fields.include?(name.to_sym)
|
|
186
|
+
unless record.respond_to?(name)
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"grid_fields slot points at `:#{name}` but " \
|
|
189
|
+
"#{record.class.name} doesn't respond to it. " \
|
|
190
|
+
"Define the method on the model or remove the slot."
|
|
191
|
+
end
|
|
192
|
+
record.public_send(name)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Resolves a slot value to an image URL. Supports:
|
|
196
|
+
# - ActiveStorage attachments (`record.avatar` -> Attached::One/Many)
|
|
197
|
+
# - Shrine uploaders (`record.avatar` -> UploadedFile, responds to :url)
|
|
198
|
+
# - Plain URL strings ("https://..." or "/uploads/...")
|
|
199
|
+
def image_src_for(value)
|
|
200
|
+
return nil if value.nil?
|
|
201
|
+
if value.respond_to?(:attached?)
|
|
202
|
+
value.attached? ? helpers.url_for(value) : nil
|
|
203
|
+
elsif value.respond_to?(:url)
|
|
204
|
+
value.url
|
|
205
|
+
elsif value.is_a?(String) && value.start_with?("http", "/")
|
|
206
|
+
value
|
|
207
|
+
end
|
|
208
|
+
rescue ArgumentError, URI::InvalidURIError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def row_actions
|
|
213
|
+
@row_actions ||= resource_definition.defined_actions.values.select { |a|
|
|
214
|
+
a.collection_record_action? && a.permitted_by?(record_policy)
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def can_show?
|
|
219
|
+
resource_definition.defined_actions[:show]&.permitted_by?(record_policy)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def record_policy
|
|
223
|
+
@record_policy ||= policy_for(record:)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def card_class
|
|
227
|
+
tokens(
|
|
228
|
+
"pu-card relative overflow-hidden transition-shadow",
|
|
229
|
+
-> { can_show? } => "cursor-pointer hover:shadow-md focus-within:ring-2 focus-within:ring-primary-500"
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|