plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,348 @@
1
+ # Model
2
+
3
+ The model layer of a resource. Includes the `Plutonium::Resource::Record` module (via inheritance from `ResourceRecord`) on top of standard `ApplicationRecord`.
4
+
5
+ ## Base class
6
+
7
+ All resource models inherit from `ResourceRecord` (created by `pu:core:install`):
8
+
9
+ ```ruby
10
+ # Main app
11
+ class Post < ResourceRecord
12
+ end
13
+
14
+ # Inside a feature package — uses the package's ResourceRecord
15
+ module Blogging
16
+ class Post < Blogging::ResourceRecord
17
+ end
18
+ end
19
+ ```
20
+
21
+ `ResourceRecord` is abstract and inherits from `ApplicationRecord`. Standard ActiveRecord features (associations, validations, scopes, callbacks, attribute macros) all work — Plutonium adds capabilities on top.
22
+
23
+ ## What `Plutonium::Resource::Record` adds
24
+
25
+ | Module | Purpose | Section |
26
+ |---|---|---|
27
+ | `HasCents` | Money handling — cents column ↔ decimal accessor | [has_cents](#has-cents) |
28
+ | `Routes` | URL parameter customization (slugs, dynamic params) | [URL routing](#url-routing) |
29
+ | `Labeling` | `to_label` for human-readable record names | [Labeling](#labeling) |
30
+ | `FieldNames` | Field introspection by category | [Field introspection](#field-introspection) |
31
+ | `Associations` | Auto-generated SGID accessors on every association | [SGID accessors](#sgid-accessors) |
32
+ | `AssociatedWith` | Multi-tenant scoping — `Model.associated_with(entity)` | [Tenancy](/reference/tenancy/entity-scoping) |
33
+
34
+ ## Section layout
35
+
36
+ Scaffolded models follow a strict ordering. Keep new code in the right section so files stay scannable:
37
+
38
+ 1. Concerns (`include`)
39
+ 2. Constants (`TYPES = {...}.freeze`)
40
+ 3. Enums
41
+ 4. Model configurations (`has_cents`)
42
+ 5. `belongs_to`
43
+ 6. `has_one`
44
+ 7. `has_many`
45
+ 8. Attachments (`has_one_attached`, `has_many_attached`)
46
+ 9. Scopes
47
+ 10. Validations
48
+ 11. Callbacks
49
+ 12. Delegations
50
+ 13. Misc macros (`has_rich_text`, `has_secure_token`, `has_secure_password`)
51
+ 14. Public methods, then `private`, then private methods
52
+
53
+ Example:
54
+
55
+ ```ruby
56
+ class Property < ResourceRecord
57
+ TYPES = {apartment: "Apartment", house: "House"}.freeze
58
+
59
+ enum :state, archived: 0, active: 1
60
+
61
+ has_cents :market_value_cents
62
+
63
+ belongs_to :company
64
+ has_one :address
65
+ has_many :units
66
+
67
+ has_one_attached :photo
68
+
69
+ scope :active, -> { where(state: :active) }
70
+
71
+ validates :name, presence: true
72
+ validates :property_code, presence: true, uniqueness: {scope: :company_id}
73
+
74
+ before_validation :generate_code, on: :create
75
+
76
+ has_rich_text :description
77
+
78
+ def full_address
79
+ address&.to_s
80
+ end
81
+
82
+ private
83
+
84
+ def generate_code
85
+ self.property_code ||= SecureRandom.hex(4).upcase
86
+ end
87
+ end
88
+ ```
89
+
90
+ ## `has_cents`
91
+
92
+ Stores monetary values as integer cents and exposes a decimal virtual accessor. Use this for money — never store decimals directly.
93
+
94
+ ```ruby
95
+ class Product < ResourceRecord
96
+ has_cents :price_cents # column: price_cents (integer); accessor: price (decimal)
97
+ has_cents :cost_cents, name: :wholesale # custom accessor name
98
+ has_cents :tax_cents, rate: 1000 # 3 decimal places (e.g. for fractional currencies)
99
+ has_cents :amount_yen, rate: 1 # currencies with no subunit (JPY)
100
+ end
101
+
102
+ product = Product.new
103
+ product.price = 19.99
104
+ product.price_cents # => 1999
105
+ product.price # => 19.99
106
+
107
+ # Truncates, never rounds
108
+ product.price = 10.999
109
+ product.price_cents # => 1099
110
+ ```
111
+
112
+ ::: danger Use the virtual accessor in policies and definitions
113
+ Reference `:price`, NOT `:price_cents`:
114
+
115
+ ```ruby
116
+ # Policy
117
+ def permitted_attributes_for_create
118
+ %i[name price] # ✅ virtual name
119
+ end
120
+
121
+ # Definition
122
+ field :price, as: :decimal # ✅ virtual name
123
+ ```
124
+
125
+ Generators sometimes emit the `_cents` name in the policy — fix by hand (and verify `has_cents` is declared on the model).
126
+ :::
127
+
128
+ ### Options
129
+
130
+ ```ruby
131
+ has_cents :field_cents,
132
+ name: :custom_name, # accessor name (default: field with _cents stripped)
133
+ rate: 100, # conversion rate (default: 100 for 2 decimal places)
134
+ suffix: "amount" # suffix for generated name when name pattern matches
135
+ ```
136
+
137
+ ### Validation propagation
138
+
139
+ Validations on the cents column automatically mark the virtual accessor invalid too:
140
+
141
+ ```ruby
142
+ class Product < ResourceRecord
143
+ has_cents :price_cents
144
+ validates :price_cents, numericality: {greater_than: 0}
145
+ end
146
+
147
+ product = Product.new(price: -10)
148
+ product.valid? # => false
149
+ product.errors[:price_cents] # => ["must be greater than 0"]
150
+ product.errors[:price] # => ["is invalid"]
151
+ ```
152
+
153
+ The framework adds an `after_validation` hook that copies `:invalid` from `price_cents` → `price` automatically — no manual wiring needed.
154
+
155
+ ### Introspection
156
+
157
+ ```ruby
158
+ Product.has_cents_attributes
159
+ # => { price_cents: { name: :price, rate: 100 } }
160
+
161
+ Product.has_cents_attribute?(:price_cents) # => true
162
+ ```
163
+
164
+ ## URL routing
165
+
166
+ ### Default
167
+
168
+ ```ruby
169
+ post.to_param # => "1" (numeric id)
170
+ # URL: /posts/1
171
+ ```
172
+
173
+ ### `path_parameter` — use a stable column
174
+
175
+ Use a column that's unique and human-readable instead of the numeric id:
176
+
177
+ ```ruby
178
+ class User < ResourceRecord
179
+ path_parameter :username
180
+ end
181
+
182
+ user = User.create(username: "john_doe")
183
+ user.to_param # => "john_doe"
184
+ # URL: /users/john_doe
185
+
186
+ User.from_path_param("john_doe") # finds by username
187
+ ```
188
+
189
+ `path_parameter` is a class-level macro (private class method). The column you pass MUST be unique — Plutonium uses it for lookup.
190
+
191
+ ### `dynamic_path_parameter` — SEO-friendly id + slug
192
+
193
+ Combines the id (for stable lookup) with a slug from another column (for SEO):
194
+
195
+ ```ruby
196
+ class Article < ResourceRecord
197
+ dynamic_path_parameter :title
198
+ end
199
+
200
+ article = Article.create(id: 42, title: "Hello World")
201
+ article.to_param # => "42-hello-world"
202
+ # URL: /articles/42-hello-world
203
+
204
+ Article.from_path_param("42-hello-world") # extracts "42", finds by id
205
+ ```
206
+
207
+ The slug is informational — only the id portion is used for lookup, so changing the title doesn't break old URLs.
208
+
209
+ ## Labeling
210
+
211
+ `to_label` provides a human-readable name for dropdowns, breadcrumbs, and display fallbacks.
212
+
213
+ ### Default resolution
214
+
215
+ 1. Returns `name` if the model has a `name` attribute.
216
+ 2. Returns `title` if the model has a `title` attribute.
217
+ 3. Falls back to `"ModelName #id"` (e.g. `"Post #42"`).
218
+
219
+ ```ruby
220
+ post = Post.new(title: "Hello World")
221
+ post.to_label # => "Hello World"
222
+
223
+ post.title = nil
224
+ post.to_label # => "Post #42"
225
+ ```
226
+
227
+ ### Override
228
+
229
+ ```ruby
230
+ class Product < ResourceRecord
231
+ def to_label
232
+ "#{name} (#{sku})"
233
+ end
234
+ end
235
+ ```
236
+
237
+ ## SGID accessors
238
+
239
+ Every association on a resource model gets Signed Global ID accessors automatically — for secure form submission, API payloads, and hidden fields without exposing database ids.
240
+
241
+ ### Singular associations (`belongs_to`, `has_one`)
242
+
243
+ ```ruby
244
+ class Post < ResourceRecord
245
+ belongs_to :user
246
+ has_one :featured_image
247
+ end
248
+
249
+ post.user_sgid # get SGID
250
+ post.user_sgid = "BAh7..." # set: locates and assigns user from SGID
251
+
252
+ post.featured_image_sgid
253
+ post.featured_image_sgid = "..."
254
+ ```
255
+
256
+ ### Collection associations (`has_many`, `has_and_belongs_to_many`)
257
+
258
+ ```ruby
259
+ class User < ResourceRecord
260
+ has_many :posts
261
+ end
262
+
263
+ user.post_sgids # => ["...", "..."]
264
+ user.post_sgids = [sgid1, sgid2] # bulk replace
265
+ user.add_post_sgid(sgid) # append
266
+ user.remove_post_sgid(sgid) # remove
267
+ ```
268
+
269
+ These are what `secure_association_tag` uses in forms — see [UI › Forms](/reference/ui/forms).
270
+
271
+ ## Field introspection
272
+
273
+ ```ruby
274
+ User.resource_field_names # all fields suitable for UI
275
+ User.content_column_field_names # database columns
276
+ User.belongs_to_association_field_names
277
+ User.has_one_association_field_names # excludes attachments
278
+ User.has_many_association_field_names # excludes attachments
279
+ User.has_one_attached_field_names # ActiveStorage single
280
+ User.has_many_attached_field_names # ActiveStorage multiple
281
+ ```
282
+
283
+ Used internally by definitions for auto-detection. You rarely call these directly, but they're useful when writing dynamic UI in `customize_fields` / custom Phlex pages.
284
+
285
+ Results are cached outside development (so changing the schema in dev hot-reloads correctly).
286
+
287
+ ## Nested attributes introspection
288
+
289
+ ```ruby
290
+ Post.all_nested_attributes_options
291
+ # => {
292
+ # comments: { allow_destroy: true, limit: 10, macro: :has_many, class: Comment },
293
+ # metadata: { update_only: true, macro: :has_one, class: PostMetadata }
294
+ # }
295
+ ```
296
+
297
+ Returns the configuration for all associations declared with `accepts_nested_attributes_for`. Used internally by `nested_input` in the definition.
298
+
299
+ ## Multi-tenancy: `associated_with`
300
+
301
+ `Plutonium::Resource::Record` provides `Model.associated_with(entity)` for multi-tenant queries:
302
+
303
+ ```ruby
304
+ Comment.associated_with(post)
305
+ # => Comment.where(post: post)
306
+ ```
307
+
308
+ Resolution order, association path requirements, three model shapes, and custom scopes are all covered in [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
309
+
310
+ ## Standard ActiveRecord features
311
+
312
+ Everything you'd expect works — associations, validations, scopes, callbacks, delegations, `has_rich_text`, `has_secure_token`, `has_one_attached`, etc. Where Plutonium adds twists:
313
+
314
+ - **Section ordering** is by convention, not enforcement — pick the right slot in the [layout above](#section-layout) so the file stays scannable.
315
+ - **Compound uniqueness for tenant-scoped resources:** `validates :code, uniqueness: {scope: :organization_id}` — without the scope, uniqueness leaks across tenants.
316
+ - **Keep models thin** — business logic that touches multiple records or has multi-step state changes belongs in [interactions](/reference/behavior/interactions), not model methods.
317
+
318
+ ## Nested resources
319
+
320
+ Plutonium auto-generates nested routes from `has_many` and `has_one` associations. No model-side change needed beyond the association itself:
321
+
322
+ ```ruby
323
+ class Comment < ResourceRecord
324
+ belongs_to :post
325
+ end
326
+ ```
327
+
328
+ When both `Post` and `Comment` are registered in a portal, `/posts/:post_id/nested_comments` exists automatically. See [Tenancy › Nested resources](/reference/tenancy/nested-resources).
329
+
330
+ ## Table naming in packages
331
+
332
+ Namespaced models use prefixed tables by default:
333
+
334
+ ```ruby
335
+ module Blogging
336
+ class Post < ResourceRecord
337
+ # table: blogging_posts
338
+ end
339
+ end
340
+ ```
341
+
342
+ Override with `self.table_name = "posts"` if you need a shared table.
343
+
344
+ ## Related
345
+
346
+ - [Definition](./definition) — controls how the model's fields render
347
+ - [Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — `associated_with`, three model shapes
348
+ - [App › Generators](/reference/app/generators) — `pu:res:scaffold` field syntax
@@ -0,0 +1,305 @@
1
+ # Query
2
+
3
+ Search, filters, scopes, and sorting for a resource's index page. All declared in the definition.
4
+
5
+ ## Overview
6
+
7
+ ```ruby
8
+ class PostDefinition < Plutonium::Resource::Definition
9
+ search do |scope, query|
10
+ scope.where("title ILIKE ?", "%#{query}%")
11
+ end
12
+
13
+ filter :title, with: :text, predicate: :contains
14
+ filter :status, with: :select, choices: %w[draft published archived]
15
+ filter :published, with: :boolean
16
+ filter :created_at, with: :date_range
17
+
18
+ scope :published
19
+ default_scope :published
20
+
21
+ sort :title
22
+ sort :created_at
23
+ default_sort :created_at, :desc
24
+ end
25
+ ```
26
+
27
+ ## Search
28
+
29
+ `search` defines global free-text search. The block receives the scope and the query string; return a filtered relation.
30
+
31
+ ```ruby
32
+ search do |scope, query|
33
+ scope.where("title ILIKE ?", "%#{query}%")
34
+ end
35
+ ```
36
+
37
+ ### Multi-field
38
+
39
+ ```ruby
40
+ search do |scope, query|
41
+ scope.where(
42
+ "title ILIKE :q OR content ILIKE :q OR author_name ILIKE :q",
43
+ q: "%#{query}%"
44
+ )
45
+ end
46
+ ```
47
+
48
+ ### Across associations
49
+
50
+ ```ruby
51
+ search do |scope, query|
52
+ scope.joins(:author).where(
53
+ "posts.title ILIKE :q OR users.name ILIKE :q",
54
+ q: "%#{query}%"
55
+ ).distinct
56
+ end
57
+ ```
58
+
59
+ ### Split terms
60
+
61
+ ```ruby
62
+ search do |scope, query|
63
+ query.split(/\s+/).reduce(scope) do |s, term|
64
+ s.where("title ILIKE ?", "%#{term}%")
65
+ end
66
+ end
67
+ ```
68
+
69
+ ### Search powers typeahead too
70
+
71
+ The same `search` block drives **typeahead lookups** on association inputs that target this resource — when you write `input :author, …` for an association, the dropdown's autocomplete calls the target resource's `search` block.
72
+
73
+ ::: tip Typeahead fallback when there's no search block
74
+ A resource without a `search` block still gets typeahead — the framework runs a case-insensitive `LIKE` against the first column that exists, in priority order:
75
+
76
+ 1. The input's `label_method:` option, if it names a real column on the model.
77
+ 2. Otherwise the first match from `[name, title, label, slug, display_name, email]`.
78
+ 3. If none exist, the relation is returned unfiltered (capped).
79
+
80
+ For large tables, write an explicit `search` block backed by a trigram or full-text index. The fallback's leading-wildcard `LIKE '%q%'` can't use a b-tree index and gets slow past a few thousand rows.
81
+ :::
82
+
83
+ ## Filters
84
+
85
+ Six built-in filter types. Use the shorthand symbol or the full class name.
86
+
87
+ | Type | Symbol | Params in URL | Options |
88
+ |---|---|---|---|
89
+ | Text | `:text` | `query` | `predicate:` |
90
+ | Boolean | `:boolean` | `value` | `true_label:`, `false_label:` |
91
+ | Date | `:date` | `value` | `predicate:` |
92
+ | Date range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
93
+ | Select | `:select` | `value` | `choices:`, `multiple:` |
94
+ | Association | `:association` | `value` | `class_name:`, `multiple:` |
95
+
96
+ ### Text predicates
97
+
98
+ `:eq`, `:not_eq`, `:contains`, `:not_contains`, `:starts_with`, `:ends_with`, `:matches`, `:not_matches`
99
+
100
+ ```ruby
101
+ filter :title, with: :text, predicate: :contains
102
+ filter :status, with: :text, predicate: :eq
103
+ filter :title, with: Plutonium::Query::Filters::Text, predicate: :contains # full class form
104
+ ```
105
+
106
+ ### Boolean
107
+
108
+ ```ruby
109
+ filter :active, with: :boolean
110
+ filter :published, with: :boolean, true_label: "Published", false_label: "Draft"
111
+ ```
112
+
113
+ ### Date
114
+
115
+ Predicates: `:eq`, `:not_eq`, `:lt`, `:lteq`, `:gt`, `:gteq`.
116
+
117
+ ```ruby
118
+ filter :created_at, with: :date, predicate: :gteq
119
+ filter :due_date, with: :date, predicate: :lt
120
+ filter :published_at, with: :date, predicate: :eq
121
+ ```
122
+
123
+ ### Date range
124
+
125
+ Two inputs (`from` + `to`):
126
+
127
+ ```ruby
128
+ filter :created_at, with: :date_range
129
+ filter :published_at, with: :date_range,
130
+ from_label: "Published from",
131
+ to_label: "Published to"
132
+ ```
133
+
134
+ ### Select
135
+
136
+ ```ruby
137
+ filter :status, with: :select, choices: %w[draft published archived]
138
+ filter :category, with: :select, choices: -> { Category.pluck(:name) }
139
+ filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
140
+ ```
141
+
142
+ ### Association
143
+
144
+ ```ruby
145
+ filter :category, with: :association
146
+ filter :author, with: :association, class_name: User
147
+ filter :tags, with: :association, class_name: Tag, multiple: true
148
+ ```
149
+
150
+ ### Custom filter (lambda)
151
+
152
+ For simple one-offs:
153
+
154
+ ```ruby
155
+ filter :published, with: ->(scope, value) {
156
+ value == "true" ? scope.where.not(published_at: nil) : scope.where(published_at: nil)
157
+ }
158
+ ```
159
+
160
+ ### Custom filter class
161
+
162
+ For anything reusable or with multiple inputs:
163
+
164
+ ```ruby
165
+ class PriceRangeFilter < Plutonium::Query::Filter
166
+ def apply(scope, min: nil, max: nil)
167
+ scope = scope.where("price >= ?", min) if min.present?
168
+ scope = scope.where("price <= ?", max) if max.present?
169
+ scope
170
+ end
171
+
172
+ def customize_inputs
173
+ input :min, as: :number
174
+ input :max, as: :number
175
+ field :min, placeholder: "Min price..."
176
+ field :max, placeholder: "Max price..."
177
+ end
178
+ end
179
+
180
+ filter :price, with: PriceRangeFilter
181
+ ```
182
+
183
+ ## Scopes
184
+
185
+ Scopes appear as quick-filter buttons across the top of the table.
186
+
187
+ ```ruby
188
+ class PostDefinition < ResourceDefinition
189
+ scope :published # uses Post.published
190
+ scope :draft # uses Post.draft
191
+
192
+ # Inline scope — block runs with the scope as argument
193
+ scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
194
+ scope(:this_month) { |s| s.where(created_at: Time.current.all_month) }
195
+ end
196
+ ```
197
+
198
+ Named scopes reference a model scope of the same name. Inline (block) scopes have access to controller context (`current_user`, `current_parent`, etc.):
199
+
200
+ ```ruby
201
+ scope(:mine) { |s| s.where(author: current_user) }
202
+ scope(:my_team) { |s| s.where(team: current_user.team) }
203
+ ```
204
+
205
+ ### Default scope
206
+
207
+ ```ruby
208
+ default_scope :published
209
+ ```
210
+
211
+ When a default is set:
212
+
213
+ - It applies on initial page load.
214
+ - The default scope button is highlighted (not "All").
215
+ - Clicking "All" shows the unscoped collection.
216
+
217
+ ## Sorting
218
+
219
+ ```ruby
220
+ sort :title
221
+ sort :created_at
222
+
223
+ sorts :title, :created_at, :view_count # shorthand for several at once
224
+
225
+ default_sort :created_at, :desc
226
+
227
+ # Complex default sort with a block
228
+ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
229
+ ```
230
+
231
+ The framework default (no `default_sort` declared, no user sort) is `id DESC`.
232
+
233
+ ## URL parameters
234
+
235
+ Query parameters are namespaced under `q`:
236
+
237
+ ```
238
+ /posts?q[search]=rails
239
+ /posts?q[title][query]=widget
240
+ /posts?q[status][value]=published
241
+ /posts?q[created_at][from]=2024-01-01&q[created_at][to]=2024-12-31
242
+ /posts?q[scope]=recent
243
+ /posts?q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
244
+ ```
245
+
246
+ Combined:
247
+
248
+ ```
249
+ /posts?q[search]=rails&q[scope]=published&q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
250
+ ```
251
+
252
+ ## Common patterns
253
+
254
+ ### Full-text search with `pg_search`
255
+
256
+ ```ruby
257
+ # Model
258
+ class Post < ResourceRecord
259
+ include PgSearch::Model
260
+ pg_search_scope :search_content, against: %i[title content]
261
+ end
262
+
263
+ # Definition
264
+ search do |scope, query|
265
+ scope.search_content(query)
266
+ end
267
+ ```
268
+
269
+ ### Status filter + scopes
270
+
271
+ ```ruby
272
+ filter :status, with: :select, choices: %w[draft published archived]
273
+ scope :draft
274
+ scope :published
275
+ scope :archived
276
+ ```
277
+
278
+ ### Date-based scopes
279
+
280
+ ```ruby
281
+ # Model
282
+ scope :today, -> { where(created_at: Time.current.all_day) }
283
+ scope :this_week, -> { where(created_at: Time.current.all_week) }
284
+ scope :this_month, -> { where(created_at: Time.current.all_month) }
285
+
286
+ # Definition
287
+ scope :today
288
+ scope :this_week
289
+ scope :this_month
290
+ ```
291
+
292
+ ## Performance
293
+
294
+ - **Add indexes** for filtered and sorted columns.
295
+ - **Use `.distinct`** when joining associations in search to avoid duplicate rows.
296
+ - **Prefer scopes over filters** for queries used often (faster, no input parsing).
297
+ - **`pg_search` / FTS** for complex search — write an explicit `search` block.
298
+ - **`LIKE '%q%'` can't use a b-tree index** — the typeahead fallback and naive search blocks get slow on large tables. Plan a trigram or full-text index when scaling.
299
+
300
+ ## Related
301
+
302
+ - [Definition](./definition) — field/input/display configuration
303
+ - [Actions](./actions) — custom and bulk actions
304
+ - [Behavior › Policy](/reference/behavior/policies) — `relation_scope` (filters records to what the user can see)
305
+ - [Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — multi-tenant filtering