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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  4. data/.claude/skills/plutonium-resource/SKILL.md +3 -2
  5. data/.claude/skills/plutonium-ui/SKILL.md +2 -0
  6. data/CHANGELOG.md +33 -0
  7. data/SECURITY.md +1 -1
  8. data/app/assets/plutonium.css +3 -1
  9. data/config/locales/en/async.yml +11 -1
  10. data/config/locales/en/dashboard.yml +1 -0
  11. data/config/locales/en/ui.yml +3 -0
  12. data/docs/public/images/reference/async-progress-page.png +0 -0
  13. data/docs/public/images/reference/async-running-banner.png +0 -0
  14. data/docs/reference/behavior/async-interactions.md +62 -5
  15. data/docs/reference/dashboard/dsl.md +54 -3
  16. data/docs/reference/generators/lite.md +30 -6
  17. data/docs/reference/i18n.md +1 -1
  18. data/docs/reference/resource/definition.md +2 -0
  19. data/docs/reference/ui/forms.md +23 -0
  20. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  21. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  22. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  23. data/gemfiles/rails_7.gemfile.lock +5 -5
  24. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  25. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  26. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  27. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  28. data/lib/plutonium/dashboard/base.rb +80 -1
  29. data/lib/plutonium/dashboard/card.rb +12 -13
  30. data/lib/plutonium/dashboard/controller.rb +28 -10
  31. data/lib/plutonium/dashboard/dsl.rb +59 -2
  32. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  33. data/lib/plutonium/dashboard/filter.rb +114 -0
  34. data/lib/plutonium/dashboard/link.rb +35 -0
  35. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  36. data/lib/plutonium/definition/presentable.rb +4 -1
  37. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  38. data/lib/plutonium/interaction/async/context.rb +12 -2
  39. data/lib/plutonium/interaction/async/executor.rb +54 -0
  40. data/lib/plutonium/interaction/async/run.rb +128 -14
  41. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  42. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  43. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  44. data/lib/plutonium/resource/query_object.rb +6 -0
  45. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  46. data/lib/plutonium/ui/avatar.rb +2 -2
  47. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  48. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  49. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  50. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  51. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  52. data/lib/plutonium/ui/form/base.rb +20 -0
  53. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  54. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  55. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  56. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  57. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  58. data/lib/plutonium/ui/form/theme.rb +15 -1
  59. data/lib/plutonium/ui/grid/card.rb +12 -4
  60. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  61. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  62. data/lib/plutonium/ui/layout/base.rb +0 -9
  63. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  64. data/lib/plutonium/ui/page/index.rb +23 -4
  65. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  66. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  67. data/lib/plutonium/version.rb +1 -1
  68. data/package.json +1 -1
  69. data/plutonium.gemspec +1 -1
  70. data/src/css/components.css +9 -0
  71. data/src/css/font_awesome.css +2328 -0
  72. data/src/css/plutonium.css +1 -0
  73. metadata +16 -4
@@ -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"
@@ -23,3 +23,4 @@ en:
23
23
  increase: "Up %{change}"
24
24
  decrease: "Down %{change}"
25
25
  unchanged: "No change"
26
+ points: "%{change} pts"
@@ -94,6 +94,9 @@ en:
94
94
  delete: "Delete"
95
95
  rating:
96
96
  clear: "Clear"
97
+ association_choices:
98
+ none: "None"
99
+ empty: "No options available"
97
100
  binary:
98
101
  replace_hint: "Stored: %{size}. Choose a file to replace it."
99
102
  display:
@@ -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
- The progress page, the table's `outcome` column, and the running banner all render `outcome`, never bare `state`.
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
- ![A run's progress page showing a Running badge, a progress bar at 27 of 50 targets, and the run's type, target type, initiator and timestamps](/images/reference/async-progress-page.png)
273
+ ![A run's progress page showing a Running badge, a progress bar at 27 of 50 targets, and the run's action, target type, initiator and timestamps](/images/reference/async-progress-page.png)
217
274
 
218
275
  And the banner it leaves on the target resource's index while it is still going:
219
276
 
220
- ![A Task index with a banner above the collection reading "Archive Tasks Async Interaction Run #19 Running", with a View progress link](/images/reference/async-running-banner.png)
277
+ ![A Task index with a banner above the collection reading "Archive all (async) #19 Running", with a View progress link](/images/reference/async-running-banner.png)
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.** 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**.
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` as a percentage from this value; a zero previous yields no percentage |
74
- | `change` | Numeric percentage points (`2.5` → `+2.5%`) or a string shown as-is |
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 (when `solid_queue` is present)
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 schedule:
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 not scheduled:
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.
@@ -145,7 +145,7 @@ end
145
145
 
146
146
  ## Derived labels
147
147
 
148
- Actions, scopes, filters, kanban columns and wizard steps default to a humanized version of their key. 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.
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
@@ -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.