plutonium 0.56.3 → 0.58.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-resource/SKILL.md +14 -0
- data/CHANGELOG.md +23 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/theme/components/HomeCta.vue +2 -2
- data/docs/public/templates/base.rb +1 -1
- data/docs/public/templates/lite.rb +9 -9
- data/docs/public/templates/plutonium.rb +10 -7
- data/docs/reference/resource/query.md +20 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/assets/assets_generator.rb +17 -0
- data/lib/generators/pu/rodauth/templates/config/initializers/url_options.rb +7 -13
- data/lib/plutonium/core/controllers/entity_scoping.rb +4 -1
- data/lib/plutonium/interaction/base.rb +7 -9
- data/lib/plutonium/invites/concerns/invite_token.rb +1 -1
- data/lib/plutonium/resource/controller.rb +15 -0
- data/lib/plutonium/resource/controllers/interactive_actions.rb +11 -0
- data/lib/plutonium/resource/query_object.rb +13 -1
- data/lib/plutonium/ui/modal/slideover.rb +1 -1
- data/lib/plutonium/ui/table/components/scopes_bar.rb +2 -1
- data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- metadata +2 -2
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
</div>
|
|
22
22
|
|
|
23
23
|
<div class="hc-term-wrap">
|
|
24
|
-
<pre class="pu-term hc-term"><span class="prompt">$</span>
|
|
24
|
+
<pre class="pu-term hc-term"><span class="prompt">$</span> {{ activeCommand }}<span class="pu-term-cursor"></span></pre>
|
|
25
25
|
<button class="hc-copy" :class="{ 'hc-copy--ok': copied }" @click="copy" :title="copied ? 'Copied' : 'Copy command'" :aria-label="copied ? 'Copied' : 'Copy command'">
|
|
26
26
|
<component :is="copied ? IconCheck : IconCopy" :size="16" :stroke-width="2" />
|
|
27
27
|
</button>
|
|
@@ -49,7 +49,7 @@ const options = [
|
|
|
49
49
|
]
|
|
50
50
|
const selected = ref("plutonium")
|
|
51
51
|
const activeUrl = computed(() => options.find(o => o.id === selected.value).url)
|
|
52
|
-
const activeCommand = computed(() => `rails new my_app -m ${activeUrl.value}`)
|
|
52
|
+
const activeCommand = computed(() => `rails new my_app -a propshaft -j esbuild -c tailwind -m ${activeUrl.value}`)
|
|
53
53
|
const copied = ref(false)
|
|
54
54
|
|
|
55
55
|
async function copy() {
|
|
@@ -2,51 +2,51 @@ after_bundle do
|
|
|
2
2
|
# SQLite infrastructure (replaces Redis/Postgres for simple deployments)
|
|
3
3
|
generate "pu:lite:setup"
|
|
4
4
|
git add: "."
|
|
5
|
-
git commit: %( -m 'setup sqlite') if `git status --porcelain`.present?
|
|
5
|
+
git commit: %( -m 'chore: setup sqlite') if `git status --porcelain`.present?
|
|
6
6
|
|
|
7
7
|
generate "pu:lite:tune"
|
|
8
8
|
git add: "."
|
|
9
|
-
git commit: %( -m 'tune sqlite pragmas') if `git status --porcelain`.present?
|
|
9
|
+
git commit: %( -m 'chore: tune sqlite pragmas') if `git status --porcelain`.present?
|
|
10
10
|
|
|
11
11
|
unless ENV["SKIP_SOLID_QUEUE"]
|
|
12
12
|
generate "pu:lite:solid_queue"
|
|
13
13
|
git add: "."
|
|
14
|
-
git commit: %( -m 'add solid_queue') if `git status --porcelain`.present?
|
|
14
|
+
git commit: %( -m 'chore: add solid_queue') if `git status --porcelain`.present?
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
unless ENV["SKIP_SOLID_CACHE"]
|
|
18
18
|
generate "pu:lite:solid_cache"
|
|
19
19
|
git add: "."
|
|
20
|
-
git commit: %( -m 'add solid_cache') if `git status --porcelain`.present?
|
|
20
|
+
git commit: %( -m 'chore: add solid_cache') if `git status --porcelain`.present?
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
unless ENV["SKIP_SOLID_CABLE"]
|
|
24
24
|
generate "pu:lite:solid_cable"
|
|
25
25
|
git add: "."
|
|
26
|
-
git commit: %( -m 'add solid_cable') if `git status --porcelain`.present?
|
|
26
|
+
git commit: %( -m 'chore: add solid_cable') if `git status --porcelain`.present?
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
unless ENV["SKIP_SOLID_ERRORS"]
|
|
30
30
|
generate "pu:lite:solid_errors"
|
|
31
31
|
git add: "."
|
|
32
|
-
git commit: %( -m 'add solid_errors') if `git status --porcelain`.present?
|
|
32
|
+
git commit: %( -m 'chore: add solid_errors') if `git status --porcelain`.present?
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
unless ENV["SKIP_LITESTREAM"]
|
|
36
36
|
generate "pu:lite:litestream"
|
|
37
37
|
git add: "."
|
|
38
|
-
git commit: %( -m 'add litestream') if `git status --porcelain`.present?
|
|
38
|
+
git commit: %( -m 'chore: add litestream') if `git status --porcelain`.present?
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
unless ENV["SKIP_RAILS_PULSE"]
|
|
42
42
|
generate "pu:lite:rails_pulse"
|
|
43
43
|
git add: "."
|
|
44
|
-
git commit: %( -m 'add rails_pulse') if `git status --porcelain`.present?
|
|
44
|
+
git commit: %( -m 'chore: add rails_pulse') if `git status --porcelain`.present?
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
unless ENV["SKIP_SQLITE_MAINTENANCE"]
|
|
48
48
|
generate "pu:lite:maintenance"
|
|
49
49
|
git add: "."
|
|
50
|
-
git commit: %( -m 'add sqlite maintenance job') if `git status --porcelain`.present?
|
|
50
|
+
git commit: %( -m 'chore: add sqlite maintenance job') if `git status --porcelain`.present?
|
|
51
51
|
end
|
|
52
52
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
after_bundle do
|
|
2
2
|
# We just installed Rails, let's create a commit
|
|
3
|
-
git(add: ".") && git(commit: %( -m 'initial commit' ))
|
|
3
|
+
git(add: ".") && git(commit: %( -m 'chore: initial commit' ))
|
|
4
4
|
|
|
5
5
|
# Run the base install
|
|
6
6
|
template_location = if ENV["LOCAL"]
|
|
@@ -12,20 +12,23 @@ after_bundle do
|
|
|
12
12
|
|
|
13
13
|
# Add development tools
|
|
14
14
|
generate "pu:gem:dotenv"
|
|
15
|
-
git(add: ".") && git(commit: %( -m 'add dotenv' ))
|
|
15
|
+
git(add: ".") && git(commit: %( -m 'chore: add dotenv' ))
|
|
16
16
|
|
|
17
17
|
generate "pu:gem:annotated"
|
|
18
|
-
git(add: ".") && git(commit: %( -m 'add annotate' ))
|
|
18
|
+
git(add: ".") && git(commit: %( -m 'chore: add annotate' ))
|
|
19
19
|
|
|
20
20
|
generate "pu:gem:standard"
|
|
21
|
-
git(add: ".") && git(commit: %( -m 'add standardrb' ))
|
|
21
|
+
git(add: ".") && git(commit: %( -m 'chore: add standardrb' ))
|
|
22
22
|
|
|
23
23
|
generate "pu:gem:letter_opener"
|
|
24
|
-
git(add: ".") && git(commit: %( -m 'add letter_opener' ))
|
|
24
|
+
git(add: ".") && git(commit: %( -m 'chore: add letter_opener' ))
|
|
25
25
|
|
|
26
26
|
generate "pu:gem:actual_db_schema"
|
|
27
|
-
git(add: ".") && git(commit: %( -m 'add actual_db_schema' ))
|
|
27
|
+
git(add: ".") && git(commit: %( -m 'chore: add actual_db_schema' ))
|
|
28
28
|
|
|
29
29
|
generate "pu:core:assets"
|
|
30
|
-
git(add: ".") && git(commit: %( -m 'integrate assets' ))
|
|
30
|
+
git(add: ".") && git(commit: %( -m 'chore: integrate assets' ))
|
|
31
|
+
|
|
32
|
+
generate "pu:skills:sync"
|
|
33
|
+
git(add: ".") && git(commit: %( -m 'chore: sync plutonium skills' ))
|
|
31
34
|
end
|
|
@@ -214,6 +214,26 @@ When a default is set:
|
|
|
214
214
|
- The default scope button is highlighted (not "All").
|
|
215
215
|
- Clicking "All" shows the unscoped collection.
|
|
216
216
|
|
|
217
|
+
### Conditional visibility — `condition:`
|
|
218
|
+
|
|
219
|
+
Like `condition:` on [actions](./actions), a scope can be **defined but only render its button when a runtime proc is truthy**. The scope (and its URL) stays live either way — `condition:` only toggles the button.
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
scope :admin_only, condition: -> { current_user.admin? }
|
|
223
|
+
scope :beta_feature, condition: -> { params[:beta] == "1" }
|
|
224
|
+
|
|
225
|
+
# Expose a scope's URL (API/programmatic) without surfacing a button
|
|
226
|
+
scope :internal, condition: -> { false }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The proc is evaluated against the view context so `current_user`, `params`, `request`, and `allowed_to?` are all available directly. There is no `object`/`record` — scopes have no single-record context.
|
|
230
|
+
|
|
231
|
+
::: danger `condition:` is NOT authorization
|
|
232
|
+
A hidden scope button still has a **live URL** anyone can navigate to. `condition:` decides whether the *button renders*, not whether the *records are accessible*.
|
|
233
|
+
|
|
234
|
+
Use `condition:` for UI relevance ("show this tab to admins only"). Use the policy's `relation_scope` to restrict which records a user can see at all.
|
|
235
|
+
:::
|
|
236
|
+
|
|
217
237
|
## Sorting
|
|
218
238
|
|
|
219
239
|
```ruby
|
|
@@ -12,6 +12,7 @@ module Pu
|
|
|
12
12
|
desc "Setup plutonium assets"
|
|
13
13
|
|
|
14
14
|
def start
|
|
15
|
+
verify_prerequisites
|
|
15
16
|
install_dependencies
|
|
16
17
|
copy_tailwind_config
|
|
17
18
|
configure_application
|
|
@@ -24,6 +25,22 @@ module Pu
|
|
|
24
25
|
|
|
25
26
|
private
|
|
26
27
|
|
|
28
|
+
# The asset pipeline assumes the app was generated with esbuild + Tailwind.
|
|
29
|
+
# Without those, `application.tailwind.css` doesn't exist and the generator
|
|
30
|
+
# later crashes with a cryptic inject_into_file error. Fail early with a fix.
|
|
31
|
+
def verify_prerequisites
|
|
32
|
+
return if File.exist?("app/assets/stylesheets/application.tailwind.css")
|
|
33
|
+
|
|
34
|
+
error <<~MSG
|
|
35
|
+
Plutonium assets require a Rails app generated with esbuild and Tailwind.
|
|
36
|
+
Expected app/assets/stylesheets/application.tailwind.css, but it is missing.
|
|
37
|
+
|
|
38
|
+
Re-create the app with the required flags:
|
|
39
|
+
rails new myapp -a propshaft -j esbuild -c tailwind \\
|
|
40
|
+
-m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
|
|
41
|
+
MSG
|
|
42
|
+
end
|
|
43
|
+
|
|
27
44
|
def copy_tailwind_config
|
|
28
45
|
copy_file "tailwind.config.js", force: true
|
|
29
46
|
copy_file "postcss.config.js", force: true
|
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
host: uri.host,
|
|
9
|
-
port: uri.port,
|
|
10
|
-
protocol: uri.scheme
|
|
11
|
-
}
|
|
12
|
-
end
|
|
3
|
+
if (default_url = ENV["RAILS_DEFAULT_URL"])
|
|
4
|
+
uri = URI.parse(default_url)
|
|
5
|
+
default_port = (uri.scheme == "https") ? 443 : 80
|
|
6
|
+
url_options = {host: uri.host, protocol: uri.scheme}
|
|
7
|
+
.tap { |opts| opts[:port] = uri.port if uri.port != default_port }
|
|
13
8
|
|
|
14
|
-
if
|
|
15
|
-
|
|
16
|
-
end
|
|
9
|
+
ActionMailer::Base.default_url_options = url_options if ActionMailer::Base.default_url_options.blank?
|
|
10
|
+
Rails.application.routes.default_url_options = url_options if Rails.application.routes.default_url_options.blank?
|
|
17
11
|
end
|
|
@@ -115,7 +115,10 @@ module Plutonium
|
|
|
115
115
|
def remember_scoped_entity
|
|
116
116
|
return unless scoped_to_entity?
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
entity = current_scoped_entity
|
|
119
|
+
return unless entity
|
|
120
|
+
|
|
121
|
+
session[scoped_entity_session_key] = entity.to_global_id.to_s
|
|
119
122
|
end
|
|
120
123
|
|
|
121
124
|
# Retrieves the remembered scoped entity from the session.
|
|
@@ -70,16 +70,14 @@ module Plutonium
|
|
|
70
70
|
#
|
|
71
71
|
# @return [Plutonium::Interaction::Outcome] The result of the interaction.
|
|
72
72
|
def call
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
outcome
|
|
80
|
-
else
|
|
81
|
-
failure.with_message("An error occurred")
|
|
73
|
+
return failure unless valid?
|
|
74
|
+
|
|
75
|
+
outcome = execute
|
|
76
|
+
unless outcome.is_a?(Plutonium::Interaction::Outcome)
|
|
77
|
+
raise "#{self.class}#execute must return an instance of Plutonium::Interaction::Outcome.\n" \
|
|
78
|
+
"#{outcome.inspect} received instead"
|
|
82
79
|
end
|
|
80
|
+
outcome
|
|
83
81
|
end
|
|
84
82
|
|
|
85
83
|
private
|
|
@@ -146,6 +146,21 @@ module Plutonium
|
|
|
146
146
|
# Pass form_action: false to prevent form from trying to generate URL (cloned record has id: nil)
|
|
147
147
|
extraction_record = resource_record?&.dup || resource_class.new
|
|
148
148
|
@submitted_resource_params ||= begin
|
|
149
|
+
# Pre-populate from submitted params so condition: procs evaluate against submitted
|
|
150
|
+
# values during extraction. Without this, a select whose choices: depend on a sibling
|
|
151
|
+
# attribute would see nil for that sibling (fresh/cloned record) and resolve to empty
|
|
152
|
+
# choices, causing AcceptsChoices to nullify a valid submitted value.
|
|
153
|
+
# attribute_names covers DB columns and `attribute :` declarations.
|
|
154
|
+
# The union with respond_to? also covers attr_accessor virtual attributes.
|
|
155
|
+
submitted = params[resource_param_key]&.to_unsafe_h || {}
|
|
156
|
+
base_keys = extraction_record.attribute_names.map(&:to_s)
|
|
157
|
+
# Also include attr_accessor virtual attributes not in attribute_names.
|
|
158
|
+
# Exclude AR association writers — they expect object instances, not param strings.
|
|
159
|
+
extra_keys = (submitted.keys.map(&:to_s) - base_keys).select { |k|
|
|
160
|
+
extraction_record.respond_to?("#{k}=") &&
|
|
161
|
+
extraction_record.class.reflect_on_association(k.to_sym).nil?
|
|
162
|
+
}
|
|
163
|
+
extraction_record.assign_attributes(submitted.slice(*(base_keys | extra_keys)))
|
|
149
164
|
extracted = build_form(extraction_record, form_action: false)
|
|
150
165
|
.extract_input(params, view_context:)[resource_param_key.to_sym].compact
|
|
151
166
|
clean_structured_inputs(current_definition, extracted)
|
|
@@ -262,6 +262,17 @@ module Plutonium
|
|
|
262
262
|
elsif action.bulk_action?
|
|
263
263
|
instance.resources = interactive_bulk
|
|
264
264
|
end
|
|
265
|
+
# Pre-populate sibling attributes from submitted params before rendering
|
|
266
|
+
# the form for extraction. When a select input's `choices:` depends on a
|
|
267
|
+
# sibling attribute and `condition:` guards it, the condition evaluates to
|
|
268
|
+
# false on a fresh instance (sibling is nil), making AcceptsChoices
|
|
269
|
+
# validate against an empty choices list and nullify a valid submitted
|
|
270
|
+
# value. Pre-populating lets conditions evaluate against submitted values,
|
|
271
|
+
# so the real select (with correct choices) is used for extraction.
|
|
272
|
+
submitted = params[:interaction]&.to_unsafe_h || {}
|
|
273
|
+
base_keys = instance.attribute_names.map(&:to_s) - %w[resource resources]
|
|
274
|
+
extra_keys = submitted.keys.map(&:to_s).select { |k| instance.respond_to?("#{k}=") } - %w[resource resources]
|
|
275
|
+
instance.assign_attributes(submitted.slice(*(base_keys | extra_keys)))
|
|
265
276
|
extracted = interaction
|
|
266
277
|
.build_form(instance)
|
|
267
278
|
.extract_input(params, view_context:)[:interaction]
|
|
@@ -31,9 +31,11 @@ module Plutonium
|
|
|
31
31
|
#
|
|
32
32
|
# @param name [Symbol] The name of the scope.
|
|
33
33
|
# @param body [Proc, nil] The body of the scope.
|
|
34
|
-
|
|
34
|
+
# @param condition [Proc, nil] Display-only visibility gate (same semantics as condition: on actions).
|
|
35
|
+
def define_scope(name, body = nil, condition: nil, **)
|
|
35
36
|
body ||= name
|
|
36
37
|
scope_definitions[name] = build_query(body)
|
|
38
|
+
scope_conditions[name] = condition if condition
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
# Defines a sort with the given name and body.
|
|
@@ -118,6 +120,16 @@ module Plutonium
|
|
|
118
120
|
|
|
119
121
|
def scope_definitions = @scope_definitions ||= {}.with_indifferent_access
|
|
120
122
|
|
|
123
|
+
def scope_conditions = @scope_conditions ||= {}.with_indifferent_access
|
|
124
|
+
|
|
125
|
+
# Display-only visibility gate for a scope, mirroring condition: on actions.
|
|
126
|
+
# Returns true when no condition is set.
|
|
127
|
+
def scope_visible?(name, view_context)
|
|
128
|
+
condition = scope_conditions[name]
|
|
129
|
+
return true if condition.nil?
|
|
130
|
+
Plutonium::Action::ConditionContext.new(view_context, nil).instance_exec(&condition)
|
|
131
|
+
end
|
|
132
|
+
|
|
121
133
|
# Returns true if user explicitly selected "All" scope (no filtering)
|
|
122
134
|
def all_scope_selected? = @all_scope_selected
|
|
123
135
|
|
|
@@ -33,7 +33,7 @@ module Plutonium
|
|
|
33
33
|
# showModal() and is dropped when the dialog leaves the top layer
|
|
34
34
|
# on close(), so it still covers the panel's slide-out. Mirrors
|
|
35
35
|
# the .pu-dialog::backdrop rule in components.css.
|
|
36
|
-
"fixed top-0 right-0 bottom-0 left-auto m-0 h-
|
|
36
|
+
"fixed top-0 right-0 bottom-0 left-auto m-0 h-dvh max-w-full max-h-dvh " \
|
|
37
37
|
"bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
|
|
38
38
|
"backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
|
|
39
39
|
"rounded-none p-0 " \
|
|
@@ -14,6 +14,7 @@ module Plutonium
|
|
|
14
14
|
div(class: "flex flex-wrap items-center gap-2") do
|
|
15
15
|
render_all_scope_button
|
|
16
16
|
current_query_object.scope_definitions.each_key do |name|
|
|
17
|
+
next unless current_query_object.scope_visible?(name, view_context)
|
|
17
18
|
render_scope_button(name)
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -60,7 +61,7 @@ module Plutonium
|
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
def render?
|
|
63
|
-
current_query_object.scope_definitions.
|
|
64
|
+
current_query_object.scope_definitions.any? { |name, _| current_query_object.scope_visible?(name, view_context) }
|
|
64
65
|
end
|
|
65
66
|
end
|
|
66
67
|
end
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: plutonium
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.58.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stefan Froelich
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-06-
|
|
10
|
+
date: 2026-06-10 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: zeitwerk
|