plutonium 0.67.0 → 0.69.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-async-interactions/SKILL.md +41 -2
- data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
- data/.claude/skills/plutonium-resource/SKILL.md +3 -2
- data/.claude/skills/plutonium-ui/SKILL.md +2 -0
- data/CHANGELOG.md +33 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +3 -1
- data/config/locales/en/async.yml +11 -1
- data/config/locales/en/dashboard.yml +1 -0
- data/config/locales/en/ui.yml +3 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/reference/behavior/async-interactions.md +62 -5
- data/docs/reference/dashboard/dsl.md +54 -3
- data/docs/reference/generators/lite.md +30 -6
- data/docs/reference/i18n.md +1 -1
- data/docs/reference/resource/definition.md +2 -0
- data/docs/reference/ui/forms.md +23 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
- data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
- data/gemfiles/rails_7.gemfile.lock +5 -5
- data/gemfiles/rails_8.0.gemfile.lock +3 -3
- data/gemfiles/rails_8.1.gemfile.lock +3 -3
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
- data/lib/plutonium/dashboard/base.rb +80 -1
- data/lib/plutonium/dashboard/card.rb +12 -13
- data/lib/plutonium/dashboard/controller.rb +28 -10
- data/lib/plutonium/dashboard/dsl.rb +59 -2
- data/lib/plutonium/dashboard/evaluation.rb +30 -0
- data/lib/plutonium/dashboard/filter.rb +114 -0
- data/lib/plutonium/dashboard/link.rb +35 -0
- data/lib/plutonium/dashboard/route_resolution.rb +6 -2
- data/lib/plutonium/definition/presentable.rb +4 -1
- data/lib/plutonium/interaction/async/configuration.rb +15 -0
- data/lib/plutonium/interaction/async/context.rb +12 -2
- data/lib/plutonium/interaction/async/executor.rb +54 -0
- data/lib/plutonium/interaction/async/run.rb +128 -14
- data/lib/plutonium/interaction/async/run_definition.rb +5 -4
- data/lib/plutonium/interaction/async/run_policy.rb +5 -5
- data/lib/plutonium/interaction/response/redirect.rb +19 -7
- data/lib/plutonium/resource/query_object.rb +6 -0
- data/lib/plutonium/routing/dashboard_registration.rb +5 -0
- data/lib/plutonium/ui/avatar.rb +2 -2
- data/lib/plutonium/ui/breadcrumbs.rb +26 -8
- data/lib/plutonium/ui/dashboard/board.rb +4 -1
- data/lib/plutonium/ui/dashboard/frame.rb +1 -1
- data/lib/plutonium/ui/dashboard/metric.rb +18 -5
- data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
- data/lib/plutonium/ui/form/base.rb +20 -0
- data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
- data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
- data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
- data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
- data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
- data/lib/plutonium/ui/form/theme.rb +15 -1
- data/lib/plutonium/ui/grid/card.rb +12 -4
- data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
- data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
- data/lib/plutonium/ui/layout/base.rb +0 -9
- data/lib/plutonium/ui/page/dashboard.rb +1 -0
- data/lib/plutonium/ui/page/index.rb +23 -4
- data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
- data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -1
- data/src/css/components.css +9 -0
- data/src/css/font_awesome.css +2328 -0
- data/src/css/plutonium.css +1 -0
- metadata +16 -4
data/config/locales/en/async.yml
CHANGED
|
@@ -7,8 +7,11 @@ en:
|
|
|
7
7
|
async:
|
|
8
8
|
run:
|
|
9
9
|
label: "%{name} #%{id}"
|
|
10
|
+
interaction: "Action"
|
|
11
|
+
progress: "%{done} / %{total}"
|
|
10
12
|
target_type: "Target type"
|
|
11
13
|
enqueue_failed: "could not be enqueued for execution"
|
|
14
|
+
reject_outside_pre_check: "%{run_class}#reject is only available inside pre_check"
|
|
12
15
|
executor:
|
|
13
16
|
target_missing: "Target %{id} is no longer available"
|
|
14
17
|
target_unauthorized: "Target %{id} is no longer permitted by %{action}"
|
|
@@ -17,6 +20,12 @@ en:
|
|
|
17
20
|
one: "stopped at the first target failure (%{policy} policy); %{count} target was not attempted"
|
|
18
21
|
other: "stopped at the first target failure (%{policy} policy); %{count} targets were not attempted"
|
|
19
22
|
partial_batch_refused: "%{unresolved} of %{total} targets could not be resolved; a %{policy} run does not apply a partial batch"
|
|
23
|
+
pre_check_arity:
|
|
24
|
+
targeted: "%{run_class}#pre_check must take the records: define pre_check(records) on a run with perform_on"
|
|
25
|
+
opaque: "%{run_class}#pre_check must take no arguments: define pre_check on a run with perform"
|
|
26
|
+
rejections_truncated:
|
|
27
|
+
one: "and %{count} more problem was found"
|
|
28
|
+
other: "and %{count} more problems were found"
|
|
20
29
|
context:
|
|
21
30
|
initiator_missing: "run %{run} has no initiator (%{type}#%{id} no longer exists)"
|
|
22
31
|
tenant_missing: "run %{run} was scoped to %{type}#%{id}, which no longer exists; refusing to resolve targets without a tenant"
|
|
@@ -25,10 +34,11 @@ en:
|
|
|
25
34
|
targets: "%{done} of %{total} targets (%{percent}%)"
|
|
26
35
|
working: "Working…"
|
|
27
36
|
no_total: "No progress total was recorded"
|
|
28
|
-
failures: "Failures"
|
|
29
37
|
error_count:
|
|
30
38
|
one: "%{count} error"
|
|
31
39
|
other: "%{count} errors"
|
|
32
40
|
target_failure: "#%{target_id}: %{message}"
|
|
41
|
+
rejected: "Nothing was processed"
|
|
33
42
|
banner:
|
|
34
43
|
view_progress: "View progress"
|
|
44
|
+
view_run: "View run"
|
data/config/locales/en/ui.yml
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -26,6 +26,8 @@ Plutonium.configure do |config|
|
|
|
26
26
|
config.async_interactions.enabled = true
|
|
27
27
|
config.async_interactions.queue = :default # ActiveJob queue for run jobs
|
|
28
28
|
config.async_interactions.stall_after = 1.hour # see Stalled runs, below
|
|
29
|
+
config.async_interactions.max_rejections = 100 # pre_check problems stored per run
|
|
30
|
+
config.async_interactions.recent_runs_window = 10.minutes # finished runs stay in your banner this long
|
|
29
31
|
end
|
|
30
32
|
```
|
|
31
33
|
|
|
@@ -123,6 +125,58 @@ Validating means staging first, since Shrine validates an assigned cached file,
|
|
|
123
125
|
|
|
124
126
|
Where no `backend:` is declared: `config.async_interactions.attachment_backend`, then `config.attachment_backend`, then auto-detection (active_shrine loaded → Shrine, else ActiveStorage). Same layering wizards use.
|
|
125
127
|
|
|
128
|
+
### Checking input before processing
|
|
129
|
+
|
|
130
|
+
Validations on the interaction run in the request, so they suit cheap checks (an uploader's size and type rules already fail the form there). A check too slow for a request, like reading every row of a large file, goes in the run's `pre_check`. It runs in the job, before any work, and refuses the input with `reject`:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
require "csv"
|
|
134
|
+
|
|
135
|
+
class ImportTasks < ResourceInteraction
|
|
136
|
+
attribute :import_file
|
|
137
|
+
input :import_file, as: :file
|
|
138
|
+
|
|
139
|
+
async do
|
|
140
|
+
def pre_check
|
|
141
|
+
each_row do |row, line|
|
|
142
|
+
reject "line #{line}: title is missing" if row["title"].blank?
|
|
143
|
+
end
|
|
144
|
+
rescue CSV::MalformedCSVError => e
|
|
145
|
+
reject "the file is not valid CSV: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# One transaction: an opaque run restarts from scratch after a reap, so a
|
|
149
|
+
# partial import would otherwise be imported twice.
|
|
150
|
+
def perform
|
|
151
|
+
Task.transaction { each_row { |row, _line| Task.create!(title: row["title"]) } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Read whole, as UTF-8 without Excel's byte-order mark: the stored file
|
|
157
|
+
# comes back as binary, and a BOM would become part of the first header.
|
|
158
|
+
def each_row
|
|
159
|
+
text = attachment(:import_file).download.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF")
|
|
160
|
+
CSV.parse(text, headers: true).each.with_index(2) do |row, line|
|
|
161
|
+
yield row, line unless row.fields.all?(&:blank?)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Rows are numbered from 2 so a message matches the line a spreadsheet shows (the header is line 1), and blank lines are skipped without shifting the numbers after them. The dummy app's `ImportTasksInteraction` is the full version, with a column check, an empty-file check and status validation.
|
|
169
|
+
|
|
170
|
+
- `reject` records a problem and returns, so one pass reports every bad row. It does not stop the check: `return` explicitly where nothing after it would make sense, such as a missing column.
|
|
171
|
+
- If anything was rejected, the run ends `failed` with `outcome == "rejected"`, the problems listed on its progress page, and `perform` / `perform_on` never runs. The page shows a warning "Rejected" badge and "Nothing was processed". On a `:continue` run, targets that had gone missing or lost permission are recorded before the check runs, so they still appear in the list (and count toward `progress_done`) even though nothing was performed.
|
|
172
|
+
- A run with `perform_on` declares `pre_check(records)` and receives the resolved, authorized records the loop is about to process. A `pre_check` with the wrong arity fails the run with a message naming the signature it expects.
|
|
173
|
+
- An exception raised inside `pre_check` fails the run the ordinary way (`outcome == "failed"`): the check broke, the input was not judged.
|
|
174
|
+
- `reject` called outside `pre_check` raises.
|
|
175
|
+
- Only the first `config.async_interactions.max_rejections` problems (default 100) are stored, followed by one "and N more problems were found" entry.
|
|
176
|
+
- The check runs again when a stalled run is resumed, so it must only read. An opaque run always re-checks. A resumed targeted run checks the records it has left, unless an earlier pass already applied a target (that pass's check passed, so it is not repeated).
|
|
177
|
+
- A targeted check receives the records as loaded when the run resolved its targets. `perform_on` gets each record re-read just before it runs, so a verdict based on record state can be stale by then; re-check anything that must hold at the moment of the work inside `perform_on`.
|
|
178
|
+
- A long check must call `heartbeat!` (see [Long work must say it is alive](#long-work-must-say-it-is-alive)).
|
|
179
|
+
|
|
126
180
|
### Sharing one run across interactions
|
|
127
181
|
|
|
128
182
|
Pass a class instead of a block when several interactions do the same kind of work:
|
|
@@ -175,7 +229,9 @@ run.state # "completed"
|
|
|
175
229
|
run.outcome # "completed_with_errors" (state == "completed" && errors_log.any?)
|
|
176
230
|
```
|
|
177
231
|
|
|
178
|
-
|
|
232
|
+
A run whose `pre_check` rejected its input reads `outcome == "rejected"` (its `state` is `"failed"`, and nothing was processed). A run that failed while working reads `"failed"`.
|
|
233
|
+
|
|
234
|
+
The progress page, the table's `outcome` column, and the running banner's entries for finished runs render `outcome`, never bare `state`.
|
|
179
235
|
|
|
180
236
|
## Registering the Run resource
|
|
181
237
|
|
|
@@ -207,17 +263,18 @@ A registered run resource gets, for free:
|
|
|
207
263
|
|
|
208
264
|
- **A progress page.** The show page IS the progress page. It self-refreshes via polling (not ActionCable) while `state` is `pending`/`running`, and stops carrying the poll once the run settles, so a finished run is a static page, not a background request per viewer.
|
|
209
265
|
- **A self-refreshing index.** The runs index polls on the same terms as the progress page: one frame around the whole collection, armed only while some run is still working, disarmed the moment none is. One request per interval regardless of page size, and it re-fetches the URL you are on, so filters, sort and page survive the refresh. A frame per row is not an option: `turbo-frame` is not in the content model of `tr`, so the parser hoists it out of the table before Turbo sees it.
|
|
210
|
-
- **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
|
|
266
|
+
- **A running banner.** Any OTHER resource's index page lists runs currently in progress against it, above the collection, so a user who dispatched a bulk action and navigated away can find it again. It also lists the viewer's own runs that finished within `config.async_interactions.recent_runs_window` (default 10 minutes, newest first, at most 5), with their outcome and a "View run" link. A dispatch sends the user back to this index, and a short run, or one its `pre_check` rejected, has often finished before the page renders. Scoped through the same `authorized_resource_scope` every cross-resource read goes through, so a run in another tenant can never surface. If a resource_class is registered in a portal that never registered `Run`, the banner is skipped there instead of raising while trying to build a link to a route that doesn't exist.
|
|
211
267
|
- **Tenant scoping.** A run's `associated_with` scope filters on the tenant it was dispatched in (recorded on the row), not on walking the object graph, since the two polymorphic tenant columns make the generic scope unusable.
|
|
268
|
+
- **Runs named by their interaction.** A run declared with an `async` block is titled by its interaction's label ("Archive all (async) #19"), from `presents label:` or, without one, the class name minus its `Interaction` suffix. A standalone run class passed to `async` can be shared by several interactions, so it is named after its own class. Only `presents label:` reaches the run: a `label:` on the `action` declaration, or a `plutonium.actions.<model>.<action>` translation, renames the button but not the runs it starts, so put the label on the interaction when both should match.
|
|
212
269
|
- **A humanized target label.** `run.target_label` reads the target class through `model_name.human` ("Post", not "Blogging::Post"), falling back to the raw string if that class has since been renamed or removed.
|
|
213
270
|
|
|
214
271
|
The progress page, mid-run:
|
|
215
272
|
|
|
216
|
-

|
|
217
274
|
|
|
218
275
|
And the banner it leaves on the target resource's index while it is still going:
|
|
219
276
|
|
|
220
|
-

|
|
221
278
|
|
|
222
279
|
## Stalled runs and ReapJob
|
|
223
280
|
|
|
@@ -255,7 +312,7 @@ What bounds that is `lock_version`. Both the reaper's resume and the executor's
|
|
|
255
312
|
|
|
256
313
|
`stall_after` is a **silence** threshold, not a runtime limit. Every write the executor makes refreshes the clock, so a targeted run with quick targets heartbeats once per target for free. Two shapes get nothing, and both are exactly the "long-running task" case:
|
|
257
314
|
|
|
258
|
-
- **Opaque work
|
|
315
|
+
- **Opaque work, and any `pre_check`.** Between the claim and `finish!` the executor writes nothing, because there is nothing to count. A `perform` that outlives `stall_after` is reaped mid-flight and, having no `handled_target_ids` to resume from, re-runs **from scratch**.
|
|
259
316
|
- **A single `perform_on`** that outlives `stall_after` on its own.
|
|
260
317
|
|
|
261
318
|
Call `heartbeat!` from inside such work:
|
|
@@ -30,6 +30,49 @@ Default reload interval for every lazy card. `nil` (the default) disables it.
|
|
|
30
30
|
|
|
31
31
|
Page width, one of `:sm`, `:md`, `:lg`, `:xl`, `:full`. Default `:full`.
|
|
32
32
|
|
|
33
|
+
### filter(key, choices: nil, scopes: nil, default: nil, label: nil)
|
|
34
|
+
|
|
35
|
+
A page-level control every card reads, such as a period or a region.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
filter :period, choices: {"7" => "Last 7 days", "30" => "Last 30 days", "90" => "Last 90 days"}, default: "30"
|
|
39
|
+
|
|
40
|
+
metric(:orders) { orders.where(created_at: filter_value(:period).to_i.days.ago..).count }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `choices:` maps each value to its label (a string or a lazy `t` proc), in display order. `default:` must be one of the values and is the first when omitted.
|
|
44
|
+
- The key cannot be one the card route or `url_for` uses (`card`, `format`, `anchor`, `host`, `use_route`, ...), which raises when declared, or a path segment of the dashboard's route (the entity param on a path-scoped portal, a `(:locale)` scope), which raises on the request.
|
|
45
|
+
- The value comes from the query string under the filter's key. A value that is not one of the choices reads as the default.
|
|
46
|
+
- Every lazy card's frame URL carries every filter's value, so a card's own request reads what the page read.
|
|
47
|
+
- The page renders each filter as a segmented row of links above the cards, so a choice is a URL that survives a reload and can be shared. Other query parameters on the page are kept.
|
|
48
|
+
- A card's `href:` is not rewritten with the filter values. Use a scope filter (below) to link a card to the matching list.
|
|
49
|
+
|
|
50
|
+
#### Scope filters
|
|
51
|
+
|
|
52
|
+
`scopes:` takes a resource instead of `choices:`. The choices are "All" plus the scopes the resource's definition declares, labelled and gated (`condition:`) the way the index's scope pills are. The default is `default:`, else the definition's `default_scope`, else "All". A default whose `condition:` hides it from the current user reads as "All" for that user.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
filter :status, scopes: Post
|
|
56
|
+
|
|
57
|
+
metric(:posts, href: -> { filtered_url(Post) }) { filtered(Post).count }
|
|
58
|
+
chart(:by_author) { filtered(Post).group(:author_name).count }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `filtered(Post)` is `authorized_resource_scope(Post)` with the chosen scope applied through the definition's own scope, the same code the index runs, so the card's number matches the list.
|
|
62
|
+
- `filtered_url(Post)` is the Post index on that scope (`q[scope]=...`). With "All" chosen and a `default_scope` on the definition, it sends an explicit blank scope so the index shows everything.
|
|
63
|
+
- Without a scope filter for the resource, `filtered` is the authorized scope as is and `filtered_url` the plain index.
|
|
64
|
+
- A scope's block runs against the dashboard's controller. `current_user`, `current_scoped_entity` and the portal's helpers work there; resource-controller helpers such as `current_parent` do not.
|
|
65
|
+
- One scope filter per resource; a second raises when declared. A `default:` that is not one of the definition's scopes, or a definition scope named `:all` (the "All" choice's value), raises on the request.
|
|
66
|
+
|
|
67
|
+
### link(key, href:, label: nil, icon: nil, condition: nil)
|
|
68
|
+
|
|
69
|
+
A button in the toolbar above the cards, such as the dashboard's settings or a full report. `href:` and `condition:` take the same forms as a card's and are evaluated on the dashboard instance. A link whose `href:` resolves to nil is left out.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
link :settings, icon: Phlex::TablerIcons::Settings, href: -> { resource_url_for(Setting) },
|
|
73
|
+
condition: -> { allowed_to?(:update?, Setting) }
|
|
74
|
+
```
|
|
75
|
+
|
|
33
76
|
## Cards
|
|
34
77
|
|
|
35
78
|
```ruby
|
|
@@ -70,8 +113,8 @@ The block returns a value or a hash.
|
|
|
70
113
|
| Hash key | Meaning |
|
|
71
114
|
|---|---|
|
|
72
115
|
| `value` | The number or string. `nil` renders `plutonium.dashboard.metric.empty` |
|
|
73
|
-
| `previous` | Computes `change`
|
|
74
|
-
| `change` |
|
|
116
|
+
| `previous` | Computes `change` from this value: a percentage of it, or the difference with `change_unit: :points`. A zero previous yields no percentage (a neutral dash when there is a caption); with `:points` the difference still shows. The change is rounded to one decimal before its trend is read |
|
|
117
|
+
| `change` | A number (`2.5` → `+2.5%`, or `+2.5 pts` with `change_unit: :points`) or a string shown as-is |
|
|
75
118
|
| `trend` | `:up`, `:down`, `:flat`; inferred from the sign of `change` |
|
|
76
119
|
| `change_label` | Caption after the change |
|
|
77
120
|
|
|
@@ -83,6 +126,7 @@ The block returns a value or a hash.
|
|
|
83
126
|
| `prefix:` / `suffix:` | String | Wrapped around the formatted value |
|
|
84
127
|
| `positive:` | `:up` (default), `:down` | Which direction is good; drives the change colour |
|
|
85
128
|
| `change_label:` | String | Same as the hash key |
|
|
129
|
+
| `change_unit:` | `:percent` (default), `:points` | How the change reads. Use `:points` for a value that is itself a percentage: 50% to 55.6% reads `+5.6 pts`, not `+11.2%` |
|
|
86
130
|
|
|
87
131
|
### chart
|
|
88
132
|
|
|
@@ -106,6 +150,11 @@ No kind-specific options. The block is `instance_exec`ed in `Plutonium::UI::Dash
|
|
|
106
150
|
|---|---|
|
|
107
151
|
| `authorize?` | Override to gate the page and every card. Default `true` |
|
|
108
152
|
| `visible_cards` | Cards whose `condition:` passes |
|
|
153
|
+
| `filter_value(key)` | The filter's value for this request, or its default |
|
|
154
|
+
| `filter_values` | `{key => value}` for every filter; what each card's frame URL carries |
|
|
155
|
+
| `filtered(resource_class)` | The authorized scope with the resource's scope filter applied |
|
|
156
|
+
| `filtered_url(resource_class)` | The resource's index on the scope that filter has chosen |
|
|
157
|
+
| `visible_links` | Toolbar links whose `condition:` passes |
|
|
109
158
|
| `visible_card!(key)` | The card, or `Plutonium::Dashboard::UnknownCardError` (404) |
|
|
110
159
|
| `refresh_for(card)` | The card's interval, else the dashboard's; `nil` for a card declared `refresh: false` |
|
|
111
160
|
| `view_context` / `helpers` | The Rails view context |
|
|
@@ -117,7 +166,9 @@ No kind-specific options. The block is `instance_exec`ed in `Plutonium::UI::Dash
|
|
|
117
166
|
|---|---|
|
|
118
167
|
| `cards` | All declared cards, in order |
|
|
119
168
|
| `find_card(key)` / `find_card!(key)` | Lookup by key |
|
|
169
|
+
| `filters` / `find_filter(key)` | Declared filters |
|
|
170
|
+
| `links` | Declared toolbar links |
|
|
120
171
|
| `label`, `description`, `icon` | Resolved presentation |
|
|
121
172
|
| `i18n_key`, `route_name` | `admin_portal/sales`, `sales` |
|
|
122
173
|
|
|
123
|
-
Subclasses inherit the parent's cards and options by copy, so a portal-specific subclass can add cards without touching the parent.
|
|
174
|
+
Subclasses inherit the parent's cards, filters, links and options by copy, so a portal-specific subclass can add cards without touching the parent.
|
|
@@ -36,19 +36,43 @@ another environment is left untouched.
|
|
|
36
36
|
|
|
37
37
|
## `pu:lite:maintenance`
|
|
38
38
|
|
|
39
|
-
Installs `app/jobs/sqlite_maintenance_job.rb` and
|
|
40
|
-
schedules it in `config/recurring.yml
|
|
39
|
+
Installs `app/jobs/sqlite_maintenance_job.rb` and, when `solid_queue` is present,
|
|
40
|
+
schedules it in `config/recurring.yml` along with SolidQueue's finished-job cleanup.
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
43
|
rails g pu:lite:maintenance
|
|
44
|
-
# custom
|
|
45
|
-
rails g pu:lite:maintenance --schedule="every day at 4am"
|
|
44
|
+
# custom schedules:
|
|
45
|
+
rails g pu:lite:maintenance --schedule="every day at 4am" \
|
|
46
|
+
--clear-finished-schedule="every 30 minutes"
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
The job runs `PRAGMA optimize` on every configured SQLite database and `VACUUM`
|
|
49
50
|
only on databases without live 24/7 writers (`primary`, `errors`, `rails_pulse`
|
|
50
51
|
by default; edit `VACUUM_DBS` in the generated job to suit your app).
|
|
51
52
|
|
|
53
|
+
Even on those databases, `VACUUM` is skipped unless at least 10% of the file's
|
|
54
|
+
pages are free (`VACUUM_MIN_FREE_RATIO`). The rewrite holds its exclusive lock for
|
|
55
|
+
as long as it takes, which on a large `primary` can be long enough for requests to
|
|
56
|
+
time out, and WAL reuses free pages anyway. In practice it runs after a bulk delete
|
|
57
|
+
and is skipped (with a log line) the rest of the time.
|
|
58
|
+
|
|
59
|
+
### Clearing finished SolidQueue jobs
|
|
60
|
+
|
|
61
|
+
SolidQueue keeps finished jobs by default (`preserve_finished_jobs`) and never
|
|
62
|
+
deletes them on its own, so the queue database grows without bound. The generator
|
|
63
|
+
adds this task:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
clear_solid_queue_finished_jobs:
|
|
67
|
+
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
68
|
+
schedule: "every hour at minute 12"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It deletes jobs finished longer ago than `clear_finished_jobs_after` (one day by
|
|
72
|
+
default) in batches, sleeping between them so the scheduler and workers keep their
|
|
73
|
+
turn at the write lock. New Rails 8 apps already ship this task; the generator
|
|
74
|
+
detects any existing `clear_finished_in_batches` entry and skips.
|
|
75
|
+
|
|
52
76
|
**Why VACUUM only some databases?** SolidQueue, Solid Cache and Solid Cable write
|
|
53
77
|
to their databases constantly. `VACUUM` takes a global *exclusive* lock for its
|
|
54
78
|
whole duration, which stalls and errors those processes (e.g. SolidQueue process
|
|
@@ -61,5 +85,5 @@ Databases listed in the job that don't exist in `config/database.yml` are skippe
|
|
|
61
85
|
at runtime, so the same job is safe regardless of which `pu:lite:*` generators you
|
|
62
86
|
have run.
|
|
63
87
|
|
|
64
|
-
If `solid_queue` is not installed, the job file is still created but
|
|
65
|
-
add a `sqlite_maintenance` entry to whatever scheduler you use.
|
|
88
|
+
If `solid_queue` is not installed, the job file is still created but nothing is
|
|
89
|
+
scheduled: add a `sqlite_maintenance` entry to whatever scheduler you use.
|
data/docs/reference/i18n.md
CHANGED
|
@@ -145,7 +145,7 @@ end
|
|
|
145
145
|
|
|
146
146
|
## Derived labels
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
Scopes, filters, kanban columns and wizard steps default to a humanized version of their key. An action backed by an interaction defaults to the interaction's class name without its `Interaction` suffix (`ArchivePostsInteraction` reads "Archive Posts"). Each has a convention key that takes precedence over that fallback, and a `label:` option that takes precedence over both. For an action backed by an interaction, the interaction's explicit `presents label:` also counts as declared and wins over the convention; only the class-name default yields to it.
|
|
149
149
|
|
|
150
150
|
```yaml
|
|
151
151
|
en:
|
|
@@ -1011,6 +1011,8 @@ end
|
|
|
1011
1011
|
|
|
1012
1012
|
Grid slots, `:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer`, are all optional. `:meta` accepts an array; the rest are single fields. Slots pointing at policy-blocked fields collapse silently.
|
|
1013
1013
|
|
|
1014
|
+
In the `:media` layout a record whose image does not resolve keeps the cover's space, with a neutral icon, so cards in a row stay one height. An image may be an attachment, an `http` or root-relative URL, or a `data:image/` URI.
|
|
1015
|
+
|
|
1014
1016
|
Only declare `index_views` explicitly to **disable** one (e.g. `index_views :grid` to drop the table view).
|
|
1015
1017
|
|
|
1016
1018
|
## Custom page classes
|
data/docs/reference/ui/forms.md
CHANGED
|
@@ -171,6 +171,8 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
|
|
|
171
171
|
| `rating_tag` | Star picker (radio buttons, no JS); an optional rating gets a Clear option |
|
|
172
172
|
| `secure_association_tag` | Association with policy-checked options (inline `+` add, typeahead) |
|
|
173
173
|
| `belongs_to_tag` / `has_many_tag` / `has_one_tag` | Association selects |
|
|
174
|
+
| `association_checkboxes_tag` / `association_toggles_tag` | has_many / habtm as a list of checkboxes or switches (see [Association inputs](#association-inputs)) |
|
|
175
|
+
| `association_radio_buttons_tag` | belongs_to / has_one as a radio group |
|
|
174
176
|
| `key_value_store_tag` | Key/value pairs editor |
|
|
175
177
|
|
|
176
178
|
```ruby
|
|
@@ -333,6 +335,27 @@ input :author, label_method: :email
|
|
|
333
335
|
For large target tables, write an explicit `search` block on the target resource definition; the fallback's leading-wildcard `LIKE` can't use a b-tree index.
|
|
334
336
|
:::
|
|
335
337
|
|
|
338
|
+
### List variants: checkboxes, toggles, radio buttons
|
|
339
|
+
|
|
340
|
+
For a short list of options, render one control per record instead of a dropdown:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
input :tags, as: :association_checkboxes # has_many / habtm
|
|
344
|
+
input :tags, as: :association_toggles # same, styled as switches
|
|
345
|
+
input :category, as: :association_radio_buttons # belongs_to / has_one
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The variants share the select's security: options come from the target's policy scope (or `choices:`), values are signed global ids, and submitted ids are checked against the authorized relation. Like the select, they always render the records the field holds now, even past `choice_limit:` or outside the viewer's scope, and accept them back on submit, so saving a form never drops a value the viewer did not touch. The inline `+` add button renders next to the list, and `add_action:`, `skip_authorization:`, `choices:` and `label_method:` work the same way.
|
|
349
|
+
|
|
350
|
+
How they differ from the select:
|
|
351
|
+
|
|
352
|
+
- **The list is capped like the select.** `choice_limit:` defaults to the select's cap. There is no typeahead, so a record past the cap can't be picked: use them for short lists, or raise `choice_limit:`.
|
|
353
|
+
- **Unchecking everything clears the association.** The checkbox list submits a blank value, so an empty selection saves as no records.
|
|
354
|
+
- **Optional radio groups get a "None" choice.** A radio group can't be cleared otherwise. Picking it clears the association. On a has_one, Active Record detaches the old record (or destroys it, with `dependent: :destroy`) as soon as the value is assigned, before the form validates, the same as picking a different record. It is left out when the field is required (a checked "None" would satisfy the browser's `required`), and `include_blank: false` drops it on an optional one. Its label is the field's `placeholder:` if set.
|
|
355
|
+
- **The macro has to fit.** Checkboxes and toggles take a has_many or habtm; radio buttons take a belongs_to or has_one. A mismatch raises `ArgumentError` when the input is built, on render and on submit, as do a polymorphic association and `group_method:`.
|
|
356
|
+
|
|
357
|
+
The list and its controls are themed by `association_checkboxes`, `association_toggles`, `association_radio_buttons` (the list), `association_checkbox`, `association_toggle`, `association_radio_button` (each control) and `association_choice_label`.
|
|
358
|
+
|
|
336
359
|
## Submit buttons
|
|
337
360
|
|
|
338
361
|
Default `render_actions` produces the primary submit, plus an optional "Save and add another" / "Update and continue editing" secondary button.
|