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.
- 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-invites/SKILL.md +41 -0
- data/.claude/skills/plutonium-views/SKILL.md +59 -0
- data/CHANGELOG.md +27 -0
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.js +404 -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/_flash.html.erb +1 -1
- 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/guides/user-invites.md +64 -0
- 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-06-multi-invite-model-support.md +1487 -0
- data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -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_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/core/update/update_generator.rb +20 -0
- data/lib/generators/pu/invites/install_generator.rb +136 -35
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
- 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/invites/concerns/invite_token.rb +11 -3
- data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
- data/lib/plutonium/invites/controller.rb +14 -1
- data/lib/plutonium/invites/pending_invite_check.rb +37 -28
- 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/interactive_actions.rb +13 -9
- data/lib/plutonium/resource/controllers/presentable.rb +11 -2
- data/lib/plutonium/resource/definition.rb +42 -0
- data/lib/plutonium/resource/policy.rb +23 -8
- 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/flatpickr_controller.js +23 -0
- 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/sidebar_controller.js +28 -1
- 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 +33 -3
|
@@ -70,21 +70,70 @@ module Pu
|
|
|
70
70
|
|
|
71
71
|
def setup_recurring_tasks
|
|
72
72
|
recurring_file = "config/recurring.yml"
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
|
@@ -125,9 +125,9 @@ module Plutonium
|
|
|
125
125
|
|
|
126
126
|
transaction do
|
|
127
127
|
update!(
|
|
128
|
-
state
|
|
129
|
-
accepted_at
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
email
|
|
135
|
-
state
|
|
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
|
|
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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
#
|
|
16
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
|
69
|
-
#
|
|
70
|
-
# @return [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,
|
|
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
|
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
|
|