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
@@ -1,198 +1,128 @@
1
1
  # Search and Filtering
2
2
 
3
- This guide covers implementing search, filters, scopes, and sorting.
3
+ Add a search box, sidebar filters, quick-scope buttons, and sortable columns to a resource's index page. All in the definition.
4
4
 
5
- ## Overview
5
+ ## Goal
6
6
 
7
- Plutonium provides built-in support for:
8
- - **Search** - Full-text search across fields
9
- - **Filters** - Input filters for specific fields (dropdown panel)
10
- - **Scopes** - Predefined query shortcuts (quick filter buttons)
11
- - **Sorting** - Column-based ordering
7
+ Users can:
12
8
 
13
- ## Search
9
+ - Type into a search box to narrow the index list.
10
+ - Click sidebar filters to narrow by status / category / date / etc.
11
+ - Click scope buttons (top-of-list quick filters) for common queries like "Published" or "My posts".
12
+ - Click column headers to sort.
13
+
14
+ ## The four pieces
15
+
16
+ | DSL | Purpose |
17
+ |---|---|
18
+ | `search` | The top-level search box. ONE block, queries you define. |
19
+ | `filter` | Sidebar filter inputs. One per filterable attribute. |
20
+ | `scope` | Quick-filter buttons across the top. References model scopes (or inline blocks). |
21
+ | `sort` / `default_sort` | Sortable columns. |
22
+
23
+ All declared in the definition.
14
24
 
15
- Define global search in the definition:
25
+ ## Quick recipe
16
26
 
17
27
  ```ruby
18
28
  class PostDefinition < ResourceDefinition
29
+ # Search box — searches title and body
19
30
  search do |scope, query|
20
- scope.where("title ILIKE ?", "%#{query}%")
31
+ scope.where("title ILIKE :q OR body ILIKE :q", q: "%#{query}%")
21
32
  end
22
- end
23
- ```
24
33
 
25
- ### Multi-Field Search
34
+ # Sidebar filters
35
+ filter :status, with: :select, choices: %w[draft published archived]
36
+ filter :title, with: :text, predicate: :contains
37
+ filter :created_at, with: :date_range
26
38
 
27
- ```ruby
28
- search do |scope, query|
29
- scope.where(
30
- "title ILIKE :q OR content ILIKE :q OR author_name ILIKE :q",
31
- q: "%#{query}%"
32
- )
33
- end
34
- ```
39
+ # Quick-filter buttons
40
+ scope :published # uses Post.published
41
+ scope :draft
35
42
 
36
- ### Search with Associations
43
+ # Default scope (the "Published" button is highlighted on initial load)
44
+ default_scope :published
37
45
 
38
- ```ruby
39
- search do |scope, query|
40
- scope.joins(:author).where(
41
- "posts.title ILIKE :q OR users.name ILIKE :q",
42
- q: "%#{query}%"
43
- ).distinct
46
+ # Sortable columns
47
+ sort :title
48
+ sort :created_at
49
+ default_sort :created_at, :desc
44
50
  end
45
51
  ```
46
52
 
47
- ### Split Search Terms
53
+ ## Search
48
54
 
49
55
  ```ruby
56
+ # Single field
50
57
  search do |scope, query|
51
- terms = query.split(/\s+/)
52
- terms.reduce(scope) do |current_scope, term|
53
- current_scope.where("title ILIKE ?", "%#{term}%")
54
- end
58
+ scope.where("title ILIKE ?", "%#{query}%")
55
59
  end
56
- ```
57
-
58
- ### Full-Text Search (PostgreSQL)
59
60
 
60
- ```ruby
61
+ # Multiple fields
61
62
  search do |scope, query|
62
63
  scope.where(
63
- "to_tsvector('english', title || ' ' || body) @@ plainto_tsquery('english', ?)",
64
- query
64
+ "title ILIKE :q OR content ILIKE :q OR author_name ILIKE :q",
65
+ q: "%#{query}%"
65
66
  )
66
67
  end
67
- ```
68
-
69
- ## Filters
70
-
71
- Plutonium provides **6 built-in filter types**. Use shorthand symbols or full class names.
72
-
73
- ### Text Filter
74
68
 
75
- String/text filtering with pattern matching:
76
-
77
- ```ruby
78
- class PostDefinition < ResourceDefinition
79
- # Shorthand (recommended)
80
- filter :title, with: :text, predicate: :contains
81
- filter :status, with: :text, predicate: :eq
82
-
83
- # Full class name also works
84
- filter :slug, with: Plutonium::Query::Filters::Text, predicate: :starts_with
69
+ # Across associations
70
+ search do |scope, query|
71
+ scope.joins(:author).where(
72
+ "posts.title ILIKE :q OR users.name ILIKE :q",
73
+ q: "%#{query}%"
74
+ ).distinct
85
75
  end
86
76
  ```
87
77
 
88
- #### Available Predicates
89
-
90
- | Predicate | SQL | Description |
91
- |-----------|-----|-------------|
92
- | `:eq` | `= value` | Exact match (default) |
93
- | `:not_eq` | `!= value` | Not equal |
94
- | `:contains` | `LIKE %value%` | Contains text |
95
- | `:not_contains` | `NOT LIKE %value%` | Does not contain |
96
- | `:starts_with` | `LIKE value%` | Starts with |
97
- | `:ends_with` | `LIKE %value` | Ends with |
98
- | `:matches` | `LIKE value` | Pattern match (`*` becomes `%`) |
99
- | `:not_matches` | `NOT LIKE value` | Does not match pattern |
100
-
101
- ### Boolean Filter
78
+ ### The `search` block also powers typeahead
102
79
 
103
- True/false filtering for boolean columns:
80
+ When an association input targets this resource, the dropdown's autocomplete calls the resource's `search` block. Same code, two surfaces.
104
81
 
105
- ```ruby
106
- # Basic
107
- filter :active, with: :boolean
108
-
109
- # Custom labels
110
- filter :published, with: :boolean, true_label: "Published", false_label: "Draft"
111
- ```
82
+ ### Without a `search` block — typeahead fallback
112
83
 
113
- Renders a select dropdown with "All", true label ("Yes"), and false label ("No").
84
+ The framework falls back to a case-insensitive `LIKE` on the first column it finds, in priority order:
114
85
 
115
- ### Date Filter
86
+ 1. The input's `label_method:` option, if it's a real column.
87
+ 2. Otherwise the first match from `[name, title, label, slug, display_name, email]`.
88
+ 3. Otherwise the relation is returned unfiltered (capped).
116
89
 
117
- Single date filtering with comparison predicates:
90
+ For large tables, write an explicit `search` block — the leading-wildcard `LIKE` can't use a b-tree index. See [Reference › Resource › Query › Search](/reference/resource/query#search).
118
91
 
119
- ```ruby
120
- filter :created_at, with: :date, predicate: :gteq # On or after
121
- filter :due_date, with: :date, predicate: :lt # Before
122
- filter :published_at, with: :date, predicate: :eq # On exact date
123
- ```
124
-
125
- #### Available Predicates
126
-
127
- | Predicate | Description |
128
- |-----------|-------------|
129
- | `:eq` | On this date (default) |
130
- | `:not_eq` | Not on this date |
131
- | `:lt` | Before date |
132
- | `:lteq` | On or before date |
133
- | `:gt` | After date |
134
- | `:gteq` | On or after date |
135
-
136
- ### Date Range Filter
137
-
138
- Filter between two dates (from/to):
139
-
140
- ```ruby
141
- # Basic
142
- filter :created_at, with: :date_range
143
-
144
- # Custom labels
145
- filter :published_at, with: :date_range,
146
- from_label: "Published from",
147
- to_label: "Published to"
148
- ```
149
-
150
- Renders two date pickers. Both are optional - users can filter with just "from" or just "to".
92
+ ## Filters
151
93
 
152
- ### Select Filter
94
+ Six built-in types. Use shorthand symbols:
153
95
 
154
- Filter from predefined choices:
96
+ | Type | Symbol | URL params | Options |
97
+ |---|---|---|---|
98
+ | Text | `:text` | `query` | `predicate:` |
99
+ | Boolean | `:boolean` | `value` | `true_label:`, `false_label:` |
100
+ | Date | `:date` | `value` | `predicate:` |
101
+ | Date range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
102
+ | Select | `:select` | `value` | `choices:`, `multiple:` |
103
+ | Association | `:association` | `value` | `class_name:`, `multiple:` |
155
104
 
156
105
  ```ruby
157
- # Static choices (array)
158
- filter :status, with: :select, choices: %w[draft published archived]
159
-
160
- # Dynamic choices (proc)
161
- filter :category, with: :select, choices: -> { Category.pluck(:name) }
162
-
163
- # Multiple selection
164
- filter :tags, with: :select, choices: %w[ruby rails js], multiple: true
106
+ filter :title, with: :text, predicate: :contains
107
+ filter :active, with: :boolean
108
+ filter :due_date, with: :date, predicate: :lt
109
+ filter :created_at, with: :date_range
110
+ filter :status, with: :select, choices: %w[draft published]
111
+ filter :category, with: :select, choices: -> { Category.pluck(:name) }
112
+ filter :author, with: :association, class_name: User
165
113
  ```
166
114
 
167
- ### Association Filter
168
-
169
- Filter by associated record:
115
+ ### Custom filter (lambda)
170
116
 
171
117
  ```ruby
172
- # Basic - infers Category class from :category key
173
- filter :category, with: :association
174
-
175
- # Explicit class
176
- filter :author, with: :association, class_name: User
177
-
178
- # Multiple selection
179
- filter :tags, with: :association, class_name: Tag, multiple: true
118
+ filter :published, with: ->(scope, value) {
119
+ value == "true" ? scope.where.not(published_at: nil) : scope.where(published_at: nil)
120
+ }
180
121
  ```
181
122
 
182
- Renders a resource select dropdown. Converts filter key to foreign key (`:category` -> `:category_id`).
183
-
184
- ### Filter Summary Table
123
+ ### Custom filter class
185
124
 
186
- | Type | Symbol | Input Params | Options |
187
- |------|--------|--------------|---------|
188
- | Text | `:text` | `query` | `predicate:` |
189
- | Boolean | `:boolean` | `value` | `true_label:`, `false_label:` |
190
- | Date | `:date` | `value` | `predicate:` |
191
- | Date Range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
192
- | Select | `:select` | `value` | `choices:`, `multiple:` |
193
- | Association | `:association` | `value` | `class_name:`, `multiple:` |
194
-
195
- ### Custom Filter Class
125
+ For reusable filters with multiple inputs:
196
126
 
197
127
  ```ruby
198
128
  class PriceRangeFilter < Plutonium::Query::Filter
@@ -205,99 +135,58 @@ class PriceRangeFilter < Plutonium::Query::Filter
205
135
  def customize_inputs
206
136
  input :min, as: :number
207
137
  input :max, as: :number
208
- field :min, placeholder: "Min price..."
209
- field :max, placeholder: "Max price..."
210
138
  end
211
139
  end
212
140
 
213
- # Use in definition
214
141
  filter :price, with: PriceRangeFilter
215
142
  ```
216
143
 
217
- ## Scopes
218
-
219
- Scopes appear as quick filter buttons. They reference model scopes or use inline blocks.
220
-
221
- ### Basic Scopes
222
-
223
- Reference existing model scopes:
144
+ ## Scopes (quick-filter buttons)
224
145
 
225
146
  ```ruby
226
147
  class PostDefinition < ResourceDefinition
227
- scope :published # Uses Post.published
228
- scope :draft # Uses Post.draft
229
- scope :featured # Uses Post.featured
230
- end
231
- ```
232
-
233
- ### Inline Scopes
234
-
235
- Use block syntax with the scope passed as an argument:
236
-
237
- ```ruby
238
- scope(:recent) { |scope| scope.where("created_at > ?", 1.week.ago) }
239
- scope(:this_month) { |scope| scope.where(created_at: Time.current.all_month) }
240
- ```
241
-
242
- ### With Controller Context
148
+ scope :published # uses Post.published
149
+ scope :draft # uses Post.draft
243
150
 
244
- Inline scopes have access to controller context like `current_user`:
151
+ # Inline scope block runs with scope as argument
152
+ scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
245
153
 
246
- ```ruby
247
- scope(:mine) { |scope| scope.where(author: current_user) }
248
- scope(:my_team) { |scope| scope.where(team: current_user.team) }
154
+ # Scope with controller context
155
+ scope(:mine) { |s| s.where(author: current_user) }
156
+ end
249
157
  ```
250
158
 
251
- ### Default Scope
159
+ Named scopes reference a model scope. Inline scopes have access to `current_user`, `current_parent`, `current_scoped_entity`.
252
160
 
253
- Set a scope as default:
161
+ ### Default scope
254
162
 
255
163
  ```ruby
256
- class PostDefinition < ResourceDefinition
257
- scope :published
258
- scope :draft
259
- scope :archived
260
-
261
- default_scope :published
262
- end
164
+ default_scope :published
263
165
  ```
264
166
 
265
- When a default scope is set:
266
- - The default scope is applied on initial page load
267
- - The default scope button is highlighted (not "All")
268
- - Clicking "All" shows all records without any scope filter
167
+ - Applied on initial page load.
168
+ - The default scope button is highlighted (not "All").
169
+ - Clicking "All" shows the unscoped collection.
269
170
 
270
171
  ## Sorting
271
172
 
272
- ### Define Sortable Fields
273
-
274
173
  ```ruby
275
- class PostDefinition < ResourceDefinition
276
- sort :title
277
- sort :created_at
278
- sort :view_count
279
-
280
- # Multiple at once
281
- sorts :title, :created_at, :view_count
282
- end
283
- ```
174
+ sort :title
175
+ sort :created_at
284
176
 
285
- ### Default Sort Order
177
+ sorts :title, :created_at, :view_count # shorthand
286
178
 
287
- ```ruby
288
- # Field and direction
289
179
  default_sort :created_at, :desc
290
- default_sort :title, :asc
291
180
 
292
- # Complex sorting with block
181
+ # Complex with a block
293
182
  default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
294
183
  ```
295
184
 
296
- **Note:** Default sort only applies when no sort params are provided. The system default is `:id, :desc`.
185
+ Framework default (nothing declared, no user sort): `id DESC`.
297
186
 
298
- ## URL Parameters
187
+ ## URL parameters
299
188
 
300
- Query parameters are structured under `q`:
189
+ Query params are namespaced under `q`:
301
190
 
302
191
  ```
303
192
  /posts?q[search]=rails
@@ -308,43 +197,37 @@ Query parameters are structured under `q`:
308
197
  /posts?q[sort_fields][]=created_at&q[sort_directions][created_at]=desc
309
198
  ```
310
199
 
311
- ## Complete Example
312
-
313
- ```ruby
314
- class ProductDefinition < ResourceDefinition
315
- # Full-text search
316
- search do |scope, query|
317
- scope.where(
318
- "name ILIKE :q OR description ILIKE :q",
319
- q: "%#{query}%"
320
- )
321
- end
322
-
323
- # Filters
324
- filter :name, with: :text, predicate: :contains
325
- filter :status, with: :select, choices: %w[draft active discontinued]
326
- filter :featured, with: :boolean
327
- filter :created_at, with: :date_range
328
- filter :price, with: :date, predicate: :gteq
329
- filter :category, with: :association
200
+ ## Performance tips
330
201
 
331
- # Quick scopes (reference model scopes)
332
- scope :active
333
- scope :featured
334
- scope(:recent) { |scope| scope.where("created_at > ?", 1.week.ago) }
202
+ - **Add indexes** for filtered and sorted columns.
203
+ - **Use `.distinct`** when joining associations in search — duplicate rows otherwise.
204
+ - **Prefer scopes over filters** for queries used often (no input parsing).
205
+ - **`LIKE '%q%'` can't use a b-tree index** — for large tables, use `pg_search` or a trigram/GIN/full-text index.
335
206
 
336
- # Default scope
337
- default_scope :active
207
+ ## Full-text search with `pg_search`
338
208
 
339
- # Sortable columns
340
- sorts :name, :price, :created_at
209
+ ```ruby
210
+ # Model
211
+ class Post < ResourceRecord
212
+ include PgSearch::Model
213
+ pg_search_scope :search_content, against: %i[title content]
214
+ end
341
215
 
342
- # Default sort: newest first
343
- default_sort :created_at, :desc
216
+ # Definition
217
+ search do |scope, query|
218
+ scope.search_content(query)
344
219
  end
345
220
  ```
346
221
 
222
+ ## Common issues
223
+
224
+ - **Filter not showing up** — make sure the attribute is in `permitted_attributes_for_index` on the policy.
225
+ - **Slow search on large tables** — `LIKE '%q%'` can't be indexed by a b-tree. Switch to FTS or trigram.
226
+ - **Duplicate rows in results** — add `.distinct` when joining associations.
227
+ - **Typeahead works on small dev tables but slows in production** — same b-tree issue. Write an explicit `search` block backed by a proper index.
228
+
347
229
  ## Related
348
230
 
349
- - [Custom Actions](./custom-actions)
350
- - [Authorization](./authorization)
231
+ - [Reference › Resource › Query](/reference/resource/query) — full surface
232
+ - [Adding resources](./adding-resources) — basic resource setup
233
+ - [Authorization](./authorization) — `permitted_attributes_for_index` gates which fields can be filtered
@@ -147,8 +147,9 @@ Output: `test/integration/<portal>_portal/<resource>_test.rb`.
147
147
  - **Nested resources need `parent:` in the DSL AND a parent record** from `parent_record!`. Both are required for path interpolation.
148
148
  - **`PortalAccess` uses `portal_access_for`**, not `resource_tests_for`. Don't mix them on the same class.
149
149
 
150
- ## See also
150
+ ## Related
151
151
 
152
- - [Authorization](/guides/authorization) — write the policy this concern verifies
153
- - [Multi-tenancy](/guides/multi-tenancy) — entity scoping that drives nested-resource tests
154
- - [Authentication](/guides/authentication) — Rodauth setup behind the default login flow
152
+ - [Reference › Testing](/reference/testing/) — full DSL reference, all concern stubs, override hooks
153
+ - [Authorization](./authorization) — write the policy this concern verifies
154
+ - [Multi-tenancy](./multi-tenancy) — entity scoping that drives nested-resource tests
155
+ - [Authentication](./authentication) — Rodauth setup behind the default login flow