plutonium 0.49.0 → 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.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-definition/SKILL.md +87 -2
  3. data/.claude/skills/plutonium-installation/SKILL.md +6 -0
  4. data/.claude/skills/plutonium-invites/SKILL.md +41 -0
  5. data/.claude/skills/plutonium-views/SKILL.md +59 -0
  6. data/CHANGELOG.md +27 -0
  7. data/app/assets/plutonium.css +2 -2
  8. data/app/assets/plutonium.js +404 -25
  9. data/app/assets/plutonium.js.map +4 -4
  10. data/app/assets/plutonium.min.js +45 -45
  11. data/app/assets/plutonium.min.js.map +4 -4
  12. data/app/views/plutonium/_flash.html.erb +1 -1
  13. data/app/views/plutonium/_resource_header.html.erb +4 -4
  14. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  15. data/app/views/resource/_resource_grid.html.erb +1 -0
  16. data/config/brakeman.ignore +25 -2
  17. data/docs/guides/user-invites.md +64 -0
  18. data/docs/reference/definition/actions.md +14 -1
  19. data/docs/reference/definition/index.md +58 -0
  20. data/docs/reference/views/index.md +43 -0
  21. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
  22. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
  23. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  24. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  25. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  26. data/gemfiles/rails_7.gemfile.lock +1 -1
  27. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  28. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  29. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  30. data/lib/generators/pu/core/update/update_generator.rb +20 -0
  31. data/lib/generators/pu/invites/install_generator.rb +136 -35
  32. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
  33. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
  34. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
  35. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
  36. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
  37. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
  38. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
  39. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
  40. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
  41. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
  42. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
  43. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
  44. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
  45. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
  46. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
  47. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  48. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
  49. data/lib/plutonium/action/base.rb +44 -1
  50. data/lib/plutonium/action/interactive.rb +1 -1
  51. data/lib/plutonium/configuration.rb +4 -0
  52. data/lib/plutonium/definition/actions.rb +3 -0
  53. data/lib/plutonium/definition/base.rb +8 -0
  54. data/lib/plutonium/definition/metadata.rb +40 -0
  55. data/lib/plutonium/definition/views.rb +94 -0
  56. data/lib/plutonium/helpers/turbo_helper.rb +1 -1
  57. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  58. data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
  59. data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
  60. data/lib/plutonium/invites/controller.rb +14 -1
  61. data/lib/plutonium/invites/pending_invite_check.rb +37 -28
  62. data/lib/plutonium/query/base.rb +8 -0
  63. data/lib/plutonium/query/filters/association.rb +30 -8
  64. data/lib/plutonium/query/filters/boolean.rb +5 -0
  65. data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
  66. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  67. data/lib/plutonium/resource/definition.rb +42 -0
  68. data/lib/plutonium/resource/policy.rb +23 -8
  69. data/lib/plutonium/resource/query_object.rb +64 -6
  70. data/lib/plutonium/testing/resource_definition.rb +2 -2
  71. data/lib/plutonium/ui/action_button.rb +4 -2
  72. data/lib/plutonium/ui/component/kit.rb +12 -0
  73. data/lib/plutonium/ui/display/base.rb +3 -1
  74. data/lib/plutonium/ui/display/resource.rb +109 -25
  75. data/lib/plutonium/ui/display/theme.rb +2 -1
  76. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  77. data/lib/plutonium/ui/empty_card.rb +1 -1
  78. data/lib/plutonium/ui/form/base.rb +29 -1
  79. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  80. data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
  81. data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
  82. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  83. data/lib/plutonium/ui/form/resource.rb +48 -9
  84. data/lib/plutonium/ui/form/theme.rb +1 -1
  85. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  86. data/lib/plutonium/ui/grid/card.rb +235 -0
  87. data/lib/plutonium/ui/grid/resource.rb +149 -0
  88. data/lib/plutonium/ui/layout/base.rb +37 -1
  89. data/lib/plutonium/ui/layout/header.rb +1 -2
  90. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  91. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  92. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  93. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  94. data/lib/plutonium/ui/modal/base.rb +109 -0
  95. data/lib/plutonium/ui/modal/centered.rb +21 -0
  96. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  97. data/lib/plutonium/ui/page/base.rb +25 -6
  98. data/lib/plutonium/ui/page/edit.rb +13 -1
  99. data/lib/plutonium/ui/page/index.rb +40 -1
  100. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  101. data/lib/plutonium/ui/page/new.rb +13 -1
  102. data/lib/plutonium/ui/page/show.rb +8 -1
  103. data/lib/plutonium/ui/page_header.rb +8 -13
  104. data/lib/plutonium/ui/panel.rb +10 -19
  105. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  106. data/lib/plutonium/ui/tab_list.rb +29 -7
  107. data/lib/plutonium/ui/table/base.rb +106 -0
  108. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  109. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  110. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  111. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  112. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  113. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  114. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  115. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  116. data/lib/plutonium/ui/table/resource.rb +158 -89
  117. data/lib/plutonium/ui/table/theme.rb +14 -5
  118. data/lib/plutonium/version.rb +1 -1
  119. data/lib/plutonium.rb +6 -0
  120. data/package.json +1 -1
  121. data/src/css/components.css +304 -131
  122. data/src/css/tokens.css +101 -85
  123. data/src/js/controllers/autosubmit_controller.js +24 -0
  124. data/src/js/controllers/bulk_actions_controller.js +15 -16
  125. data/src/js/controllers/capture_url_controller.js +14 -0
  126. data/src/js/controllers/filter_panel_controller.js +77 -19
  127. data/src/js/controllers/flatpickr_controller.js +23 -0
  128. data/src/js/controllers/frame_navigator_controller.js +34 -6
  129. data/src/js/controllers/icon_rail_controller.js +22 -0
  130. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  131. data/src/js/controllers/register_controllers.js +16 -0
  132. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  133. data/src/js/controllers/row_click_controller.js +21 -0
  134. data/src/js/controllers/sidebar_controller.js +28 -1
  135. data/src/js/controllers/table_column_menu_controller.js +43 -0
  136. data/src/js/controllers/table_header_controller.js +16 -0
  137. data/src/js/controllers/view_switcher_controller.js +29 -0
  138. metadata +33 -3
@@ -70,21 +70,70 @@ module Pu
70
70
 
71
71
  def setup_recurring_tasks
72
72
  recurring_file = "config/recurring.yml"
73
- return unless File.exist?(File.expand_path(recurring_file, destination_root))
73
+ full_path = File.expand_path(recurring_file, destination_root)
74
+ return unless File.exist?(full_path)
74
75
  return if file_includes?(recurring_file, "rails_pulse")
75
76
 
76
- recurring_tasks = <<~YAML
77
+ content = File.read(full_path)
78
+ env_keys = %w[production development staging test]
79
+ env_scoped = content.lines.any? { |l| l.match?(/^(#{env_keys.join("|")}):\s*$/) }
77
80
 
81
+ if env_scoped
82
+ create_file recurring_file, inject_rails_pulse_under_envs(content, env_keys), force: true
83
+ else
84
+ append_to_file recurring_file, "\n" + rails_pulse_tasks_yaml(0)
85
+ end
86
+ end
87
+
88
+ def rails_pulse_tasks_yaml(indent)
89
+ pad = " " * indent
90
+ <<~YAML.gsub(/^(?=.)/, pad)
78
91
  rails_pulse_summary:
79
92
  class: RailsPulse::SummaryJob
80
- schedule: "5 * * * *" # 5 minutes past every hour
93
+ queue: default
94
+ schedule: every hour at minute 5
95
+ description: "Roll up Rails Pulse raw records into summary tables"
81
96
 
82
97
  rails_pulse_cleanup:
83
98
  class: RailsPulse::CleanupJob
84
- schedule: "0 1 * * *" # Daily at 1am
99
+ queue: default
100
+ schedule: every day at 1am
101
+ description: "Archive/purge old Rails Pulse data"
85
102
  YAML
103
+ end
104
+
105
+ def inject_rails_pulse_under_envs(content, env_keys)
106
+ lines = content.lines
107
+ env_re = /^(#{env_keys.join("|")}):\s*$/
108
+
109
+ env_starts = lines.each_with_index.select { |l, _| env_re.match?(l) }.map(&:last)
110
+
111
+ env_starts.reverse_each do |start|
112
+ end_idx = lines.length
113
+ ((start + 1)...lines.length).each do |i|
114
+ if lines[i].match?(/^[^\s#]/)
115
+ end_idx = i
116
+ break
117
+ end
118
+ end
119
+
120
+ indent = 2
121
+ ((start + 1)...end_idx).each do |i|
122
+ if (m = lines[i].match(/^(\s+)\S/))
123
+ indent = m[1].length
124
+ break
125
+ end
126
+ end
127
+
128
+ insert_at = end_idx
129
+ while insert_at > start + 1 && lines[insert_at - 1].strip.empty?
130
+ insert_at -= 1
131
+ end
132
+
133
+ lines.insert(insert_at, "\n", rails_pulse_tasks_yaml(indent))
134
+ end
86
135
 
87
- append_to_file recurring_file, recurring_tasks
136
+ lines.join
88
137
  end
89
138
  end
90
139
  end
@@ -7,7 +7,11 @@
7
7
  <meta name="csrf-param" content="authenticity_token" />
8
8
  <meta name="csrf-token" content="<%%= form_authenticity_token %>" />
9
9
  <%%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
10
- <%%= javascript_importmap_tags %>
10
+ <%% if defined?(Importmap) %>
11
+ <%%= javascript_importmap_tags %>
12
+ <%% else %>
13
+ <%%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
14
+ <%% end %>
11
15
  </head>
12
16
  <body class="antialiased min-h-screen bg-[var(--pu-body)]">
13
17
  <main class="p-4 min-h-screen flex flex-col items-center justify-center gap-2 px-6 py-8 mx-auto lg:py-0">
@@ -16,7 +16,7 @@ module Plutonium
16
16
  # @attr_reader [Symbol, nil] category The category of the action.
17
17
  # @attr_reader [Integer] position The position of the action within its category.
18
18
  class Base
19
- attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :turbo_frame, :color, :category, :position, :return_to
19
+ attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :turbo_frame, :color, :category, :position, :return_to, :modal
20
20
 
21
21
  # Initialize a new action.
22
22
  #
@@ -57,6 +57,8 @@ module Plutonium
57
57
  @resource_action = options[:resource_action] || false
58
58
  @category = ActiveSupport::StringInquirer.new((options[:category] || :secondary).to_s)
59
59
  @position = options[:position] || 50
60
+ @modal = options[:modal] || :centered
61
+ validate_modal!
60
62
 
61
63
  freeze
62
64
  end
@@ -85,8 +87,49 @@ module Plutonium
85
87
  policy.allowed_to?(:"#{name}?")
86
88
  end
87
89
 
90
+ # Returns a new Action with the given options merged over this one.
91
+ # Used by the resource definition to derive variants (e.g. dropping
92
+ # `turbo_frame` when `modal false` is configured) without mutating
93
+ # the frozen original.
94
+ def with(**overrides)
95
+ self.class.new(name, **to_options.merge(overrides))
96
+ end
97
+
98
+ protected
99
+
100
+ # Canonical representation for reconstruction via `with`. Every
101
+ # attribute set in `initialize` MUST appear here; otherwise
102
+ # `with(**overrides)` would silently drop it on round-trip.
103
+ # `category` is exposed as a Symbol since `initialize` re-wraps
104
+ # it in StringInquirer.
105
+ def to_options
106
+ {
107
+ label: @label,
108
+ description: @description,
109
+ icon: @icon,
110
+ color: @color,
111
+ confirmation: @confirmation,
112
+ route_options: @route_options,
113
+ turbo: @turbo,
114
+ turbo_frame: @turbo_frame,
115
+ return_to: @return_to,
116
+ bulk_action: @bulk_action,
117
+ collection_record_action: @collection_record_action,
118
+ record_action: @record_action,
119
+ resource_action: @resource_action,
120
+ category: @category.to_sym,
121
+ position: @position,
122
+ modal: @modal
123
+ }
124
+ end
125
+
88
126
  private
89
127
 
128
+ def validate_modal!
129
+ return if [:centered, :slideover].include?(@modal)
130
+ raise ArgumentError, "modal must be :centered or :slideover, got #{@modal.inspect}"
131
+ end
132
+
90
133
  # Build RouteOptions from the provided options
91
134
  #
92
135
  # @param [RouteOptions, Hash, nil] options The routing options
@@ -22,7 +22,7 @@ module Plutonium
22
22
  options[:label] ||= interaction.label
23
23
  options[:description] ||= interaction.description
24
24
  options[:icon] ||= interaction.icon
25
- options[:turbo_frame] = "remote_modal" unless options.key?(:turbo_frame)
25
+ options[:turbo_frame] = Plutonium::REMOTE_MODAL_FRAME unless options.key?(:turbo_frame)
26
26
 
27
27
  super(name, **options)
28
28
  end
@@ -27,6 +27,9 @@ module Plutonium
27
27
  # @return [Float] the current defaults version
28
28
  attr_reader :defaults_version
29
29
 
30
+ # @return [Symbol] :classic (legacy Header/Sidebar) or :modern (Topbar/IconRail)
31
+ attr_accessor :shell
32
+
30
33
  # Map of version numbers to their default configurations
31
34
  VERSION_DEFAULTS = {
32
35
  1.0 => proc do |config|
@@ -48,6 +51,7 @@ module Plutonium
48
51
  @development = parse_boolean_env("PLUTONIUM_DEV")
49
52
  @cache_discovery = !Rails.env.development?
50
53
  @enable_hotreload = Rails.env.development?
54
+ @shell = :modern
51
55
  end
52
56
 
53
57
  # Load default configuration for a specific version
@@ -32,6 +32,9 @@ module Plutonium
32
32
 
33
33
  # standard CRUD actions
34
34
 
35
+ # turbo_frame for :new and :edit is set by
36
+ # Resource::Definition.configure_crud_modal_targets! based on the
37
+ # `modal` config. Don't hard-code it here.
35
38
  action(:new, route_options: {action: :new},
36
39
  resource_action: true, category: :primary,
37
40
  icon: Phlex::TablerIcons::Plus, position: 10)
@@ -33,6 +33,8 @@ module Plutonium
33
33
  include Scoping
34
34
  include Search
35
35
  include NestedInputs
36
+ include Views
37
+ include Metadata
36
38
 
37
39
  class IndexPage < Plutonium::UI::Page::Index; end
38
40
 
@@ -48,6 +50,8 @@ module Plutonium
48
50
 
49
51
  class Table < Plutonium::UI::Table::Resource; end
50
52
 
53
+ class Grid < Plutonium::UI::Grid::Resource; end
54
+
51
55
  class Display < Plutonium::UI::Display::Resource; end
52
56
 
53
57
  class QueryForm < Plutonium::UI::Form::Query; end
@@ -114,6 +118,10 @@ module Plutonium
114
118
  self.class::Table
115
119
  end
116
120
 
121
+ def grid_class
122
+ self.class::Grid
123
+ end
124
+
117
125
  def detail_class
118
126
  self.class::Display
119
127
  end
@@ -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
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # DSL for declaring which index views a resource supports and how
6
+ # they're configured.
7
+ #
8
+ # @example Enable both views, default to Grid
9
+ # class UserDefinition < Plutonium::Resource::Definition
10
+ # views :table, :grid
11
+ # default_view :grid
12
+ #
13
+ # grid_fields(
14
+ # image: :avatar,
15
+ # header: :name,
16
+ # subheader: :email,
17
+ # meta: [:role, :status]
18
+ # )
19
+ # end
20
+ module Views
21
+ extend ActiveSupport::Concern
22
+
23
+ KNOWN_VIEWS = %i[table grid].freeze
24
+ GRID_SLOTS = %i[image header subheader body meta footer].freeze
25
+ GRID_LAYOUTS = %i[compact media].freeze
26
+
27
+ included do
28
+ class_attribute :defined_views, default: [:table], instance_accessor: false
29
+ class_attribute :defined_default_view, default: nil, instance_accessor: false
30
+ class_attribute :defined_grid_fields, default: {}, instance_accessor: false
31
+ class_attribute :defined_grid_layout, default: :compact, instance_accessor: false
32
+ class_attribute :defined_grid_columns, default: nil, instance_accessor: false
33
+ end
34
+
35
+ class_methods do
36
+ # Declares the index views this resource supports.
37
+ # @param list [Array<Symbol>] one or more of {KNOWN_VIEWS}
38
+ def views(*list)
39
+ list = list.flatten.map(&:to_sym)
40
+ invalid = list - KNOWN_VIEWS
41
+ raise ArgumentError, "Unknown views: #{invalid.inspect}. Valid: #{KNOWN_VIEWS}" if invalid.any?
42
+ self.defined_views = list.empty? ? [:table] : list
43
+ end
44
+
45
+ # Declares the default index view. Must be one of {.views}.
46
+ # Falls back to the first declared view if unset.
47
+ def default_view(name = nil)
48
+ if name.nil?
49
+ defined_default_view || defined_views.first
50
+ else
51
+ name = name.to_sym
52
+ unless defined_views.include?(name)
53
+ raise ArgumentError, "default_view #{name.inspect} not in views #{defined_views.inspect}"
54
+ end
55
+ self.defined_default_view = name
56
+ end
57
+ end
58
+
59
+ # Maps grid slots to fields. Each slot is optional. Implicitly
60
+ # adds `:grid` to {.views} so a resource can opt into the Grid
61
+ # view simply by declaring its slots.
62
+ # @param slots [Hash{Symbol => Symbol, Array<Symbol>}]
63
+ def grid_fields(**slots)
64
+ invalid = slots.keys - GRID_SLOTS
65
+ raise ArgumentError, "Unknown grid slots: #{invalid.inspect}. Valid: #{GRID_SLOTS}" if invalid.any?
66
+ self.defined_grid_fields = slots
67
+ self.defined_views = defined_views + [:grid] unless defined_views.include?(:grid)
68
+ end
69
+
70
+ # Layout shape for grid cards. :compact (default) places the image
71
+ # left of the content; :media stacks the image full-width on top.
72
+ def grid_layout(value)
73
+ value = value.to_sym
74
+ unless GRID_LAYOUTS.include?(value)
75
+ raise ArgumentError, "grid_layout must be one of #{GRID_LAYOUTS}, got #{value.inspect}"
76
+ end
77
+ self.defined_grid_layout = value
78
+ end
79
+
80
+ # Override responsive column count. Default is 1 / 2 / 3 / 4 at
81
+ # sm / md / lg / xl.
82
+ def grid_columns(value)
83
+ self.defined_grid_columns = Integer(value)
84
+ end
85
+ end
86
+
87
+ def defined_views = self.class.defined_views
88
+ def default_view = self.class.default_view
89
+ def defined_grid_fields = self.class.defined_grid_fields
90
+ def defined_grid_layout = self.class.defined_grid_layout
91
+ def defined_grid_columns = self.class.defined_grid_columns
92
+ end
93
+ end
94
+ end
@@ -6,7 +6,7 @@ module Plutonium
6
6
  end
7
7
 
8
8
  def remote_modal_frame_tag(&)
9
- turbo_frame_tag("remote_modal", &)
9
+ turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME, &)
10
10
  end
11
11
  end
12
12
  end
@@ -29,7 +29,7 @@ module Plutonium
29
29
 
30
30
  respond_to do |format|
31
31
  format.turbo_stream do
32
- if helpers.current_turbo_frame == "remote_modal"
32
+ if helpers.current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
33
33
  render turbo_stream: [
34
34
  helpers.turbo_stream_redirect(url)
35
35
  ]
@@ -125,9 +125,9 @@ module Plutonium
125
125
 
126
126
  transaction do
127
127
  update!(
128
- state: :accepted,
129
- accepted_at: Time.current,
130
- user: user
128
+ :state => :accepted,
129
+ :accepted_at => Time.current,
130
+ user_attribute => user
131
131
  )
132
132
 
133
133
  create_membership_for(user)
@@ -135,6 +135,14 @@ module Plutonium
135
135
  end
136
136
  end
137
137
 
138
+ # Override to specify the user association name on the invite model.
139
+ # Defaults to :user. Override when the invite model's `belongs_to`
140
+ # uses a different name (e.g., :spender_account, :staff_user).
141
+ # @return [Symbol]
142
+ def user_attribute
143
+ :user
144
+ end
145
+
138
146
  # Override this method to specify the mailer class.
139
147
  #
140
148
  # @return [Class] the mailer class for sending invitation emails
@@ -37,12 +37,12 @@ module Plutonium
37
37
 
38
38
  def execute
39
39
  attrs = {
40
- entity: entity,
41
40
  email: email,
42
41
  role: role,
43
42
  invited_by: current_user,
44
43
  **additional_invite_attributes
45
44
  }
45
+ attrs[invite_entity_attribute] = entity
46
46
  attrs[:invitable] = invitable if invitable.present?
47
47
 
48
48
  invite_class.create!(attrs)
@@ -109,6 +109,15 @@ module Plutonium
109
109
  entity.class.name.underscore.to_sym
110
110
  end
111
111
 
112
+ # Override to specify the entity association name on the invite model.
113
+ # Defaults to :entity, matching the convention documented on InviteToken.
114
+ # When the invite model uses a concrete `belongs_to :<entity_name>`
115
+ # instead, override this to return that association name.
116
+ # @return [Symbol]
117
+ def invite_entity_attribute
118
+ :entity
119
+ end
120
+
112
121
  def role_is_present
113
122
  errors.add(:role, :blank) if role.blank?
114
123
  end
@@ -130,9 +139,9 @@ module Plutonium
130
139
  return unless email.present? && entity.present?
131
140
 
132
141
  pending = invite_class.find_by(
133
- entity: entity,
134
- email: email,
135
- state: :pending
142
+ invite_entity_attribute => entity,
143
+ :email => email,
144
+ :state => :pending
136
145
  )
137
146
  errors.add(:email, "already has a pending invitation") if pending
138
147
  end
@@ -35,6 +35,7 @@ module Plutonium
35
35
  extend ActiveSupport::Concern
36
36
 
37
37
  included do
38
+ append_view_path File.expand_path("app/views", Plutonium.root)
38
39
  helper_method :current_user if respond_to?(:helper_method)
39
40
  end
40
41
 
@@ -72,7 +73,7 @@ module Plutonium
72
73
  return unless (@invite = load_and_validate_invite(params[:token]))
73
74
 
74
75
  unless current_user
75
- redirect_to invitation_path(token: params[:token]),
76
+ redirect_to invitation_path_for(params[:token]),
76
77
  alert: "Please sign in to accept this invitation"
77
78
  return
78
79
  end
@@ -174,6 +175,18 @@ module Plutonium
174
175
  raise NotImplementedError, "#{self.class}#invite_class must return the invite model class"
175
176
  end
176
177
 
178
+ # Override to customize the invitation URL helper.
179
+ # Default uses Rails' `invitation_path(token:)` helper, which is what
180
+ # `pu:invites:install` generates for single-flow apps. Multi-flow apps
181
+ # whose generator scoped the route as `<prefix>_invitation_path` should
182
+ # override this.
183
+ #
184
+ # @param token [String] the invitation token
185
+ # @return [String] the URL path
186
+ def invitation_path_for(token)
187
+ invitation_path(token: token)
188
+ end
189
+
177
190
  # Override to specify the user model class.
178
191
  #
179
192
  # @return [Class] the user model class
@@ -8,39 +8,34 @@ module Plutonium
8
8
  # (e.g., WelcomeController, DashboardController) to check for
9
9
  # pending invitations stored in cookies.
10
10
  #
11
- # @example Basic usage
12
- # class WelcomeController < ApplicationController
13
- # include Plutonium::Invites::PendingInviteCheck
11
+ # Hosts may override either `invite_classes` (preferred — returns
12
+ # an Array of invite classes to check, in priority order) or
13
+ # `invite_class` (single class, kept for backward compatibility).
14
14
  #
15
- # def index
16
- # return if redirect_to_pending_invite!
17
- #
18
- # # Normal post-login flow...
19
- # redirect_to dashboard_path
20
- # end
21
- #
22
- # private
23
- #
24
- # def invite_class
25
- # Invites::UserInvite
26
- # end
15
+ # @example Single invite class
16
+ # def invite_class
17
+ # ::Invites::UserInvite
27
18
  # end
28
19
  #
20
+ # @example Multiple invite classes
21
+ # def invite_classes
22
+ # [::Invites::FunderInvite, ::Invites::SpenderInvite]
23
+ # end
29
24
  module PendingInviteCheck
30
25
  extend ActiveSupport::Concern
31
26
 
27
+ included do
28
+ append_view_path File.expand_path("app/views", Plutonium.root)
29
+ end
30
+
32
31
  private
33
32
 
34
33
  # Check for a pending invitation and redirect if found.
35
- #
36
- # @return [Boolean] true if redirected, false otherwise
37
34
  def redirect_to_pending_invite!
38
35
  token = cookies.encrypted[:pending_invitation]
39
36
  return false unless token
40
37
 
41
- invite = invite_class.find_for_acceptance(token)
42
-
43
- if invite
38
+ if find_pending_invite(token)
44
39
  redirect_to invitation_path(token: token)
45
40
  true
46
41
  else
@@ -49,14 +44,12 @@ module Plutonium
49
44
  end
50
45
  end
51
46
 
52
- # Returns the pending invite if one exists.
53
- #
54
- # @return [Object, nil] the pending invite or nil
47
+ # Returns the pending invite if one exists across any invite_classes.
55
48
  def pending_invite
56
49
  token = cookies.encrypted[:pending_invitation]
57
50
  return nil unless token
58
51
 
59
- invite = invite_class.find_for_acceptance(token)
52
+ invite = find_pending_invite(token)
60
53
  unless invite
61
54
  cookies.delete(:pending_invitation)
62
55
  return nil
@@ -65,11 +58,27 @@ module Plutonium
65
58
  invite
66
59
  end
67
60
 
68
- # Override to specify the invite model class.
69
- #
70
- # @return [Class] the invite model class
61
+ # Override to specify multiple invite model classes (preferred).
62
+ # Defaults to `[invite_class]` for backward compatibility.
63
+ # @return [Array<Class>]
64
+ def invite_classes
65
+ [invite_class]
66
+ end
67
+
68
+ # Override to specify a single invite model class. Maintained for
69
+ # backward compatibility; prefer `invite_classes` for multi-flow apps.
70
+ # @return [Class]
71
71
  def invite_class
72
- raise NotImplementedError, "#{self.class}#invite_class must return the invite model class"
72
+ raise NotImplementedError,
73
+ "#{self.class}#invite_class or #invite_classes must return the invite model class(es)"
74
+ end
75
+
76
+ def find_pending_invite(token)
77
+ invite_classes.each do |klass|
78
+ invite = klass.find_for_acceptance(token)
79
+ return invite if invite
80
+ end
81
+ nil
73
82
  end
74
83
  end
75
84
  end
@@ -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: false, **)
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
- foreign_key = :"#{key}_id"
31
- if @multiple && value.is_a?(Array)
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