plutonium 0.65.0 β 0.66.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/SKILL.md +43 -43
- data/.claude/skills/plutonium-app/SKILL.md +101 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +144 -133
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +151 -96
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +21 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/blog/introducing-plutonium-dashboards.md +4 -5
- data/docs/blog/introducing-plutonium-i18n.md +4 -5
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +1 -1
- data/docs/guides/index.md +3 -3
- data/docs/guides/kanban.md +55 -55
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/reference/app/generators.md +22 -22
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +75 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +6 -6
- data/docs/reference/dashboard/dsl.md +2 -2
- data/docs/reference/dashboard/index.md +1 -1
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +23 -0
- data/docs/reference/index.md +1 -1
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +32 -32
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +17 -15
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +73 -73
- data/docs/reference/resource/export.md +6 -6
- data/docs/reference/resource/index.md +16 -16
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/resource/positioning.md +78 -76
- data/docs/reference/resource/query.md +13 -13
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +57 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +14 -14
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +44 -44
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -10
- metadata +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Positioning & Drag-to-Reorder
|
|
2
2
|
|
|
3
|
-
Manual ordering for a resource: a decimal `position` column on the model, a `position_on` line in the definition, and Plutonium renders a drag grip on the index **table** and the **card grid
|
|
3
|
+
Manual ordering for a resource: a decimal `position` column on the model, a `position_on` line in the definition, and Plutonium renders a drag grip on the index **table** and the **card grid**, and on any **nested association table** of the same resource.
|
|
4
4
|
|
|
5
5
|
Ordering is **fractional**. A drop writes one decimal (the midpoint between its two neighbours), so the common case updates exactly one row. No `UPDATE β¦ SET position = position + 1` sweep across the table.
|
|
6
6
|
|
|
@@ -8,12 +8,12 @@ The same machinery drives the [kanban board](/reference/kanban/), which is why a
|
|
|
8
8
|
|
|
9
9
|
## π¨ Critical
|
|
10
10
|
|
|
11
|
-
- **The concern is `Plutonium::Positioning::Model`, not `Plutonium::Positioning`.** This changed
|
|
11
|
+
- **The concern is `Plutonium::Positioning::Model`, not `Plutonium::Positioning`.** This changed, see [the upgrade note](#upgrading-from-include-plutonium-positioning) before you touch anything.
|
|
12
12
|
- **`position_on` silently sets `default_sort`** when your definition hasn't declared one. See [the warning](#what-position-on-expands-to).
|
|
13
13
|
- **The model owns storage; the definition owns the UI.** `positioned_on` (model) says *how positions are stored*. `position_on` (definition) says *this list is orderable*. Never restate the column or the scope in the definition.
|
|
14
14
|
- **Dragging is offered only while the collection is sorted ascending by the position attribute.** Under any other sort the grip renders as a link back to that sort, and the server rejects the drop outright.
|
|
15
|
-
- **`reposition?` on the policy gates the drop.** It defaults to `update?`. Override it to let someone reorder without granting full edit access
|
|
16
|
-
- **Prefer [Mode A](#mode-a-delegate)
|
|
15
|
+
- **`reposition?` on the policy gates the drop.** It defaults to `update?`. Override it to let someone reorder without granting full edit access, or to forbid reordering while still allowing edits.
|
|
16
|
+
- **Prefer [Mode A](#mode-a-delegate): the framework-owned write.** A block ([Mode B](#mode-b)) is a supported escape hatch for models already ordered by a positioning gem, but it hands you semantics Mode A handles for you. Already on `acts_as_list`? [Migrating](#migrating-off-a-positioning-gem) is a column change, two lines on the model, and a backfill.
|
|
17
17
|
|
|
18
18
|
## Two verbs, one feature {#two-verbs-one-feature}
|
|
19
19
|
|
|
@@ -24,7 +24,7 @@ There are exactly two, and the split is deliberate:
|
|
|
24
24
|
| `positioned_on :column, scope: :attr` | the **model** | *How are positions stored?* Which column, and what groups rows into independent orderings. |
|
|
25
25
|
| `position_on` | the **definition** (and inside `kanban doβ¦end`) | *Is this list orderable, and who writes the new position?* |
|
|
26
26
|
|
|
27
|
-
The definition-layer verb is `position_on` on both a definition and a kanban board
|
|
27
|
+
The definition-layer verb is `position_on` on both a definition and a kanban board, the **same** verb, not a third one. A board with no `position_on` of its own inherits the definition's. So the framework's whole positioning vocabulary is two words, and you learn the board by learning the table.
|
|
28
28
|
|
|
29
29
|
```ruby
|
|
30
30
|
class Task < ApplicationRecord
|
|
@@ -43,7 +43,7 @@ That second line names neither the column nor the scope. It reads them off the m
|
|
|
43
43
|
|
|
44
44
|
## Quick start
|
|
45
45
|
|
|
46
|
-
**1. Migration
|
|
46
|
+
**1. Migration.** Use the `t.position` helper. It emits a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so the scale can't be too small to rebalance cleanly. It works in `create_table` and `change_table` alike:
|
|
47
47
|
|
|
48
48
|
```ruby
|
|
49
49
|
create_table :tasks do |t|
|
|
@@ -61,7 +61,7 @@ t.position index: true # also add a single-column index
|
|
|
61
61
|
t.position scale: 10 # override precision/scale
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
**2. Model
|
|
64
|
+
**2. Model.** Include the concern and declare the column:
|
|
65
65
|
|
|
66
66
|
```ruby
|
|
67
67
|
class Task < ApplicationRecord
|
|
@@ -72,7 +72,7 @@ class Task < ApplicationRecord
|
|
|
72
72
|
end
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
**3. Definition
|
|
75
|
+
**3. Definition.** One line:
|
|
76
76
|
|
|
77
77
|
```ruby
|
|
78
78
|
class TaskDefinition < Plutonium::Resource::Definition
|
|
@@ -90,11 +90,11 @@ That's it. The index table and the card grid now render a drag grip, `POST /task
|
|
|
90
90
|
|
|
91
91
|
---
|
|
92
92
|
|
|
93
|
-
## The model layer
|
|
93
|
+
## The model layer: `Plutonium::Positioning::Model`
|
|
94
94
|
|
|
95
95
|
### Upgrading from `include Plutonium::Positioning` {#upgrading-from-include-plutonium-positioning}
|
|
96
96
|
|
|
97
|
-
::: danger Breaking change
|
|
97
|
+
::: danger Breaking change: the concern moved down a level
|
|
98
98
|
`Plutonium::Positioning` is now a **pure namespace**. The ActiveRecord concern is `Plutonium::Positioning::Model`.
|
|
99
99
|
|
|
100
100
|
```ruby
|
|
@@ -105,9 +105,9 @@ include Plutonium::Positioning
|
|
|
105
105
|
include Plutonium::Positioning::Model
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
`positioned_on`, `reposition!` and `backfill_positions!` are unchanged
|
|
108
|
+
`positioned_on`, `reposition!` and `backfill_positions!` are unchanged; only the `include` line moves.
|
|
109
109
|
|
|
110
|
-
**Why it had to change.** Every constant nested inside an included module joins the including class's constant lookup. While the concern *was* `Plutonium::Positioning`, a bare `Config` written anywhere inside a positioned model resolved to `Plutonium::Positioning::Config` instead of the application's own `::Config
|
|
110
|
+
**Why it had to change.** Every constant nested inside an included module joins the including class's constant lookup. While the concern *was* `Plutonium::Positioning`, a bare `Config` written anywhere inside a positioned model resolved to `Plutonium::Positioning::Config` instead of the application's own `::Config`, silently and with no error, in a class the app author never suspected. Splitting the namespace from the mixin stops the leak: `Model` nests nothing.
|
|
111
111
|
:::
|
|
112
112
|
|
|
113
113
|
### `positioned_on(column = :position, scope: nil)`
|
|
@@ -124,7 +124,7 @@ After the call the model gains:
|
|
|
124
124
|
- `backfill_positions!(order: :created_at)` on the class.
|
|
125
125
|
|
|
126
126
|
::: warning Including the concern is not enough
|
|
127
|
-
`include Plutonium::Positioning::Model` without a `positioned_on` call installs no `before_create` hook
|
|
127
|
+
`include Plutonium::Positioning::Model` without a `positioned_on` call installs no `before_create` hook: every row is created with a `NULL` position and the list orders arbitrarily. `position_on` in the definition raises at class-load rather than let that ship.
|
|
128
128
|
:::
|
|
129
129
|
|
|
130
130
|
### `reposition!(prev_record:, next_record:)`
|
|
@@ -139,22 +139,22 @@ task.reposition!(prev_record: last, next_record: nil) # append
|
|
|
139
139
|
|
|
140
140
|
It returns a `Plutonium::Positioning::Result`, whose **`rebalanced?`** tells the caller whether rows *other than this one* moved. That is the signal the drop endpoint uses to decide between "204, nothing to repaint" and "here is the whole collection back".
|
|
141
141
|
|
|
142
|
-
The arithmetic, the `EPSILON = 1e-6` rebalance threshold, and the pure `Plutonium::Positioning.position_between` / `.gap_exhausted?` helpers are documented in full under [Kanban βΊ Positioning](/reference/kanban/positioning)
|
|
142
|
+
The arithmetic, the `EPSILON = 1e-6` rebalance threshold, and the pure `Plutonium::Positioning.position_between` / `.gap_exhausted?` helpers are documented in full under [Kanban βΊ Positioning](/reference/kanban/positioning); the model layer is shared, so there is one description of it and both surfaces point at it.
|
|
143
143
|
|
|
144
144
|
---
|
|
145
145
|
|
|
146
|
-
## The definition layer
|
|
146
|
+
## The definition layer: `position_on`
|
|
147
147
|
|
|
148
148
|
### Four forms
|
|
149
149
|
|
|
150
150
|
```ruby
|
|
151
|
-
position_on # Mode A
|
|
152
|
-
position_on :sort_order # Mode A
|
|
153
|
-
position_on(:rank) { |move| β¦ } # Mode B
|
|
154
|
-
position_on false # Mode C
|
|
151
|
+
position_on # Mode A: follow the model's column β use this
|
|
152
|
+
position_on :sort_order # Mode A: must MATCH the model's column
|
|
153
|
+
position_on(:rank) { |move| β¦ } # Mode B: another gem owns the write (escape hatch)
|
|
154
|
+
position_on false # Mode C: ordering off
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
[Mode A](#mode-a-delegate) is the one to reach for: the framework owns the write, and the bare form cannot disagree with the model. [Mode B](#mode-b) exists for models already ordered by a positioning gem
|
|
157
|
+
[Mode A](#mode-a-delegate) is the one to reach for: the framework owns the write, and the bare form cannot disagree with the model. [Mode B](#mode-b) exists for models already ordered by a positioning gem; it is supported and tested, but it hands you semantics Mode A handles, so prefer [migrating off the gem](#migrating-off-a-positioning-gem) where you can.
|
|
158
158
|
|
|
159
159
|
### What `position_on` expands to {#what-position-on-expands-to}
|
|
160
160
|
|
|
@@ -169,9 +169,9 @@ action :reposition, hidden: true # route + policy predicate, no button
|
|
|
169
169
|
The `sort` registration is load-bearing rather than a convenience: dragging is only permitted while the list is in ascending position order, so without a permitted sort there would be **no way back out** of the disabled state.
|
|
170
170
|
|
|
171
171
|
::: warning `position_on` claims `default_sort`
|
|
172
|
-
If your definition has not declared a `default_sort`, `position_on` sets it to `<attribute>, :asc
|
|
172
|
+
If your definition has not declared a `default_sort`, `position_on` sets it to `<attribute>, :asc`, replacing the framework default of `id, :desc`. A resource that used to list newest-first will list in position order after you add this line.
|
|
173
173
|
|
|
174
|
-
That is almost always what you want (a hand-ordered list that ignores its own order is useless), but it is a change you did not write. To keep a different default, just declare one
|
|
174
|
+
That is almost always what you want (a hand-ordered list that ignores its own order is useless), but it is a change you did not write. To keep a different default, just declare one, **in either order**, above or below `position_on`:
|
|
175
175
|
|
|
176
176
|
```ruby
|
|
177
177
|
class TaskDefinition < Plutonium::Resource::Definition
|
|
@@ -180,7 +180,7 @@ class TaskDefinition < Plutonium::Resource::Definition
|
|
|
180
180
|
end
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
-
`position_on` only claims `default_sort` while nobody has declared one, so an explicit declaration always wins regardless of where it sits in the class body. Note the consequence: with a foreign default sort, the list opens **not draggable
|
|
183
|
+
`position_on` only claims `default_sort` while nobody has declared one, so an explicit declaration always wins regardless of where it sits in the class body. Note the consequence: with a foreign default sort, the list opens **not draggable**: the grip renders as a link that applies the position sort.
|
|
184
184
|
|
|
185
185
|
"Explicit" is by declaration, not by value: `default_sort :id, :desc` wins too, even though it names the same field and direction as the framework default. Writing it means you chose it.
|
|
186
186
|
|
|
@@ -192,7 +192,7 @@ class ResourceDefinition < Plutonium::Resource::Definition
|
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
class TaskDefinition < ResourceDefinition
|
|
195
|
-
position_on # inherits :created_at
|
|
195
|
+
position_on # inherits :created_at: NOT draggable on open
|
|
196
196
|
end
|
|
197
197
|
```
|
|
198
198
|
|
|
@@ -206,7 +206,7 @@ end
|
|
|
206
206
|
```
|
|
207
207
|
:::
|
|
208
208
|
|
|
209
|
-
### Mode A
|
|
209
|
+
### Mode A: delegate (the default) {#mode-a-delegate}
|
|
210
210
|
|
|
211
211
|
The framework owns the write. On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`.
|
|
212
212
|
|
|
@@ -220,12 +220,12 @@ Mode A validates the model **at class-load**, with errors that name the fix:
|
|
|
220
220
|
| Situation | Result |
|
|
221
221
|
|---|---|
|
|
222
222
|
| Model does not `include Plutonium::Positioning::Model` | `ArgumentError` pointing at the concern, and at Mode B as the escape hatch |
|
|
223
|
-
| Model includes it but never calls `positioned_on` | `ArgumentError
|
|
224
|
-
| `position_on :rank` while the model says `positioned_on :position` | `ArgumentError
|
|
223
|
+
| Model includes it but never calls `positioned_on` | `ArgumentError`: no `before_create`, so every row would sort arbitrarily |
|
|
224
|
+
| `position_on :rank` while the model says `positioned_on :position` | `ArgumentError`, the list would be *ordered* by one column while `reposition!` *wrote* another, so dragging would appear to do nothing |
|
|
225
225
|
|
|
226
226
|
That last one is why the bare form is the recommended one: it cannot disagree with the model.
|
|
227
227
|
|
|
228
|
-
### Mode B
|
|
228
|
+
### Mode B: bring your own positioning gem {#mode-b}
|
|
229
229
|
|
|
230
230
|
::: tip Reach for Mode A first
|
|
231
231
|
Mode A is one word in the definition. The *correct* `acts_as_list` block further down this page is fifteen lines of rank arithmetic, and writing it means already knowing three things neither library's README tells you:
|
|
@@ -234,12 +234,12 @@ Mode A is one word in the definition. The *correct* `acts_as_list` block further
|
|
|
234
234
|
- **removing a record shifts its neighbours' ranks by one**, in a direction that depends on whether the record started above or below them;
|
|
235
235
|
- **a blank `move.prev` means "nothing above me *on screen*"**, not "top of the list".
|
|
236
236
|
|
|
237
|
-
These docs got two of those three wrong for a while
|
|
237
|
+
These docs got two of those three wrong for a while, in the very section written to explain the first. That is the honest case for preferring Mode A. Not that Mode B is broken: it is supported, it is [tested against the real gem](#why-move-index-cannot-be-the-anchor), and the recipe below is correct. But its correctness lives in arithmetic you own and have to keep owning, and Mode A's does not.
|
|
238
238
|
|
|
239
239
|
Choosing today? Choose Mode A. Already on a positioning gem? [Migrating](#migrating-off-a-positioning-gem) is a column change, two lines on the model, and a backfill.
|
|
240
240
|
:::
|
|
241
241
|
|
|
242
|
-
Give `position_on` a block and Plutonium stops writing positions. It still **orders** the collection by the attribute you name, still renders the grip, still routes and authorizes the drop
|
|
242
|
+
Give `position_on` a block and Plutonium stops writing positions. It still **orders** the collection by the attribute you name, still renders the grip, still routes and authorizes the drop, but the block persists the new value.
|
|
243
243
|
|
|
244
244
|
The block receives a single `Plutonium::Positioning::Move`:
|
|
245
245
|
|
|
@@ -248,10 +248,10 @@ The block receives a single `Plutonium::Positioning::Move`:
|
|
|
248
248
|
| `move.record` | the dropped record |
|
|
249
249
|
| `move.prev` | the record immediately **before** the slot **on the client's page**, or `nil` |
|
|
250
250
|
| `move.next` | the record immediately **after** the slot **on the client's page**, or `nil` |
|
|
251
|
-
| `move.index` | 0-based insertion index among the other rows **on that page
|
|
252
|
-
| `move.column` | the destination kanban column key
|
|
251
|
+
| `move.index` | 0-based insertion index among the other rows **on that page**: see [why it cannot be your anchor](#why-move-index-cannot-be-the-anchor) |
|
|
252
|
+
| `move.column` | the destination kanban column key: `nil` on tables and grids, which have no columns |
|
|
253
253
|
|
|
254
|
-
It is called with `call`, not `instance_exec
|
|
254
|
+
It is called with `call`, not `instance_exec`; `self` inside the block is wherever you wrote it.
|
|
255
255
|
|
|
256
256
|
#### What the framework stops doing {#mode-b-handover}
|
|
257
257
|
|
|
@@ -259,11 +259,11 @@ A block is an opaque write. Plutonium cannot know what it touched, or against wh
|
|
|
259
259
|
|
|
260
260
|
| | Mode A | Mode B |
|
|
261
261
|
|---|---|---|
|
|
262
|
-
| **Hidden boundary neighbours** | resolved server-side before the write, so a drop at the edge of a page anchors to the real row the client couldn't see | not resolved
|
|
262
|
+
| **Hidden boundary neighbours** | resolved server-side before the write, so a drop at the edge of a page anchors to the real row the client couldn't see | not resolved, `resolve_position_boundaries` returns early unless the config delegates. The block gets the client's viewport verbatim, `nil` and all |
|
|
263
263
|
| **Drop under a foreign sort** | rejected `422` before any write | not checked server-side. Only the client-side gate applies; the block owns its own notion of neighbours |
|
|
264
264
|
| **Response** | `204` when nothing else moved | always `200` + a turbo-stream of the collection |
|
|
265
265
|
|
|
266
|
-
That last row is not a missing optimisation. Gems in this space routinely renumber the entire group on every move
|
|
266
|
+
That last row is not a missing optimisation. Gems in this space routinely renumber the entire group on every move (`acts_as_list` does exactly that), so the client's optimistic DOM is stale by definition. A repaint per drop is the only way the two are guaranteed to agree.
|
|
267
267
|
|
|
268
268
|
The first two rows are the ones to weigh before choosing Mode B: they are the semantics you are taking on, and the worked example below is what taking them on looks like.
|
|
269
269
|
|
|
@@ -271,7 +271,7 @@ The first two rows are the ones to weigh before choosing Mode B: they are the se
|
|
|
271
271
|
|
|
272
272
|
If nothing external depends on the gem's contiguous integer ranks, moving to Mode A is a column change, two lines on the model, and a backfill.
|
|
273
273
|
|
|
274
|
-
**1. Change the column.** `acts_as_list` stores contiguous integers; Plutonium stores fractional decimals, and `t.position` emits `decimal(16, 8)` for exactly that reason
|
|
274
|
+
**1. Change the column.** `acts_as_list` stores contiguous integers; Plutonium stores fractional decimals, and `t.position` emits `decimal(16, 8)` for exactly that reason: a whole-number column would round every midpoint straight back onto a neighbour. `t.position` *adds* a column, so an existing one wants `change_column`:
|
|
275
275
|
|
|
276
276
|
```ruby
|
|
277
277
|
class ChangeTaskPositionToDecimal < ActiveRecord::Migration[8.0]
|
|
@@ -281,7 +281,9 @@ class ChangeTaskPositionToDecimal < ActiveRecord::Migration[8.0]
|
|
|
281
281
|
end
|
|
282
282
|
```
|
|
283
283
|
|
|
284
|
-
|
|
284
|
+
Match the `ActiveRecord::Migration[...]` version to your app's existing migrations (or generate the file with `rails g migration`) rather than copying `[8.0]`.
|
|
285
|
+
|
|
286
|
+
**2. Swap the macro on the model.** Note that `scope:` takes a bare Symbol here: the [Array-form trap](#worked-example-acts-as-list) goes away with the gem:
|
|
285
287
|
|
|
286
288
|
```ruby
|
|
287
289
|
class Task < ApplicationRecord
|
|
@@ -299,18 +301,18 @@ end
|
|
|
299
301
|
Task.backfill_positions!(order: :position)
|
|
300
302
|
```
|
|
301
303
|
|
|
302
|
-
Then drop the block from the definition
|
|
304
|
+
Then drop the block from the definition; a bare `position_on` is the whole of Mode A.
|
|
303
305
|
|
|
304
306
|
::: warning `backfill_positions!` is a one-shot
|
|
305
|
-
It loads the table, groups it in Ruby, and writes every row with `update_column
|
|
307
|
+
It loads the table, groups it in Ruby, and writes every row with `update_column`: no callbacks, no validations, no `updated_at`. That is what you want for a backfill and not what you want in a request. Run it from a migration or a `rails runner`, once.
|
|
306
308
|
:::
|
|
307
309
|
|
|
308
310
|
#### Worked example: staying on `acts_as_list` {#worked-example-acts-as-list}
|
|
309
311
|
|
|
310
|
-
For when the gem is not yours to remove
|
|
312
|
+
For when the gem is not yours to remove: another codepath calls `move_higher`, a report reads the integer ranks, or the migration simply isn't due yet. This block is correct and stays correct; it is just longer than the one word above.
|
|
311
313
|
|
|
312
314
|
::: danger Anchor off the neighbours, never off `move.index`
|
|
313
|
-
`insert_at(move.index + 1)` is the obvious block to write and it is **wrong on any list that paginates or filters
|
|
315
|
+
`insert_at(move.index + 1)` is the obvious block to write and it is **wrong on any list that paginates or filters**, which, since Plutonium paginates every index at 20 rows by default, means wrong on any list with 21 rows in it. The numbers are in [Why `move.index` cannot be the anchor](#why-move-index-cannot-be-the-anchor). Use the block below.
|
|
314
316
|
:::
|
|
315
317
|
|
|
316
318
|
```ruby
|
|
@@ -326,7 +328,7 @@ class Task < ApplicationRecord
|
|
|
326
328
|
end
|
|
327
329
|
|
|
328
330
|
class TaskDefinition < Plutonium::Resource::Definition
|
|
329
|
-
# No Plutonium::Positioning::Model, no positioned_on
|
|
331
|
+
# No Plutonium::Positioning::Model, no positioned_on: acts_as_list owns
|
|
330
332
|
# both the column and the write. Plutonium only orders, routes and authorizes.
|
|
331
333
|
position_on :position do |move|
|
|
332
334
|
record = move.record
|
|
@@ -334,7 +336,7 @@ class TaskDefinition < Plutonium::Resource::Definition
|
|
|
334
336
|
target =
|
|
335
337
|
if move.prev
|
|
336
338
|
# Land immediately after prev. When the record currently sits ABOVE
|
|
337
|
-
# prev, removing it shifts prev up one
|
|
339
|
+
# prev, removing it shifts prev up one: so prev's own rank is already
|
|
338
340
|
# the slot the record should occupy.
|
|
339
341
|
(record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
|
|
340
342
|
elsif move.next
|
|
@@ -351,19 +353,19 @@ class TaskDefinition < Plutonium::Resource::Definition
|
|
|
351
353
|
end
|
|
352
354
|
```
|
|
353
355
|
|
|
354
|
-
`insert_at(n)` in `acts_as_list` means "end up at rank `n` after the move", and every rank in the expression above is a **real** rank read off a neighbour record
|
|
356
|
+
`insert_at(n)` in `acts_as_list` means "end up at rank `n` after the move", and every rank in the expression above is a **real** rank read off a neighbour record, never a viewport offset. `default_sort :position, :asc` is registered for you, which is exactly the order `acts_as_list` maintains.
|
|
355
357
|
|
|
356
358
|
Both the `elsif move.next` branch and the `else 1` are load-bearing. A blank `prev` means "nothing above me **on my screen**"; falling straight through to rank 1 sends the row to the top of the whole list, past every row the page or the filter hid.
|
|
357
359
|
|
|
358
360
|
::: tip `insert_at` swallows a failed save
|
|
359
|
-
`insert_at` calls `save`, not `save!`, so a record that fails validation mid-move silently no-ops
|
|
361
|
+
`insert_at` calls `save`, not `save!`, so a record that fails validation mid-move silently no-ops; the drop answers `200` and the streamed collection shows the row back where it started. Use `insert_at!` if you would rather the endpoint surface the errors as a `422` with a toast, the validation-failure row of [the response table](#the-endpoint).
|
|
360
362
|
:::
|
|
361
363
|
|
|
362
364
|
#### Why `move.index` cannot be the anchor {#why-move-index-cannot-be-the-anchor}
|
|
363
365
|
|
|
364
|
-
Plutonium hands a Mode B block the client's neighbours **verbatim
|
|
366
|
+
Plutonium hands a Mode B block the client's neighbours **verbatim**; it does not look up the rows pagination or a filter hid, the way Mode A does. So `move.index` is a claim about the **viewport**, while `insert_at` addresses the **whole scope group**. The two only agree on an unfiltered page 1.
|
|
365
367
|
|
|
366
|
-
These are measured, not reasoned
|
|
368
|
+
These are measured, not reasoned: `test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb` drives each one through `POST <member>/reposition` against the real gem:
|
|
367
369
|
|
|
368
370
|
| List | Gesture | `insert_at(move.index + 1)` | Anchored off neighbours |
|
|
369
371
|
|---|---|---|---|
|
|
@@ -372,15 +374,15 @@ These are measured, not reasoned β `test/plutonium/resource/controllers/positi
|
|
|
372
374
|
| 10 rows, filter shows ranks 1 and 10 | drag rank 1 below the filtered row at rank 10 (`to_index: 1`) | `insert_at(2)` β the row moves **one slot**, staying at the top | rank **10**, below the hidden rows |
|
|
373
375
|
| 10 rows, filter shows ranks 5 and 10 | drag rank 10 above the filtered row at rank 5 (`prev_id` blank, `to_index: 0`) | `insert_at(1)` β **rank 1**, above four rows the filter hid | rank **5**, immediately above the row it was dropped on |
|
|
374
376
|
|
|
375
|
-
Mode A has none of this to think about: it resolves hidden boundary neighbours server-side before the write (see [Nested resources & scope groups](#nested-resources-scope-groups)). Mode B is a real escape hatch and the block above is a correct one
|
|
377
|
+
Mode A has none of this to think about: it resolves hidden boundary neighbours server-side before the write (see [Nested resources & scope groups](#nested-resources-scope-groups)). Mode B is a real escape hatch and the block above is a correct one; the price of the hatch is simply that the semantics are yours. If nothing outside the gem depends on those integer ranks, [the migration](#migrating-off-a-positioning-gem) hands them back.
|
|
376
378
|
|
|
377
|
-
### Mode C
|
|
379
|
+
### Mode C: disabled
|
|
378
380
|
|
|
379
381
|
```ruby
|
|
380
382
|
position_on false
|
|
381
383
|
```
|
|
382
384
|
|
|
383
|
-
No ordering is applied (the relation passes through unchanged), no `sort`/`default_sort` is registered, no `reposition` action is created, and the endpoint answers **404**. Useful to switch a resource's ordering off in one portal while another keeps it
|
|
385
|
+
No ordering is applied (the relation passes through unchanged), no `sort`/`default_sort` is registered, no `reposition` action is created, and the endpoint answers **404**. Useful to switch a resource's ordering off in one portal while another keeps it; see [portal overrides](/reference/resource/definition).
|
|
384
386
|
|
|
385
387
|
### Kanban boards inherit the definition's `position_on`
|
|
386
388
|
|
|
@@ -407,7 +409,7 @@ class TaskDefinition < Plutonium::Resource::Definition
|
|
|
407
409
|
end
|
|
408
410
|
```
|
|
409
411
|
|
|
410
|
-
Resolution is **lazy**, so declaration order in the class body does not matter
|
|
412
|
+
Resolution is **lazy**, so declaration order in the class body does not matter, a `kanban doβ¦end` written above `position_on` still picks it up.
|
|
411
413
|
|
|
412
414
|
---
|
|
413
415
|
|
|
@@ -415,14 +417,14 @@ Resolution is **lazy**, so declaration order in the class body does not matter
|
|
|
415
417
|
|
|
416
418
|
A drop says "put me between these two rows". That only describes a position when the **visual order is the stored order**. Under a title sort the neighbours say nothing; under a *descending* position sort they say the opposite of what the write would assume.
|
|
417
419
|
|
|
418
|
-
So the grip is live only when the collection is sorted **ascending, by the position attribute, and by nothing else
|
|
420
|
+
So the grip is live only when the collection is sorted **ascending, by the position attribute, and by nothing else**, whether that comes from the default sort or from the user clicking the column header. Both halves of the feature enforce the same rule from the same predicate:
|
|
419
421
|
|
|
420
|
-
- **Client
|
|
421
|
-
- **Server
|
|
422
|
+
- **Client**: the Stimulus controller is not even attached under a foreign sort. There is nothing to drag against.
|
|
423
|
+
- **Server**: a Mode A drop arriving under a foreign sort is rejected with `422` **before any write**, and the collection is streamed back with a toast. (Mode B has no server-side sort check: the block owns its own notion of neighbours, so only the client-side gate applies.)
|
|
422
424
|
|
|
423
425
|
### The disabled grip is the way out of the disabled state
|
|
424
426
|
|
|
425
|
-
Under a foreign sort the grip does not disappear
|
|
427
|
+
Under a foreign sort the grip does not disappear; it renders as a **link that applies the ascending position sort**. Hiding it would leave the user with no hint that the list is reorderable at all, and no way to make it so. This is precisely why `position_on` registers `sort <attribute>`.
|
|
426
428
|
|
|
427
429
|
Per record, the grip is also gated on `reposition?`: a row this viewer may not reorder renders exactly as it did before, with no grip at all. Offering an affordance that can only ever answer `403` is worse than offering none.
|
|
428
430
|
|
|
@@ -439,20 +441,20 @@ Per record, the grip is also gated on `reposition?`: a row this viewer may not r
|
|
|
439
441
|
|
|
440
442
|
On a table the grip sits *inside* the first cell, pulled left into padding the cell already had, so the cell's content does not shift by a pixel whether the grip is there or not. It appears on hover, and on keyboard focus.
|
|
441
443
|
|
|
442
|
-
::: details Why only the grip is draggable on a table
|
|
444
|
+
::: details Why only the grip is draggable on a table, but the whole card is on a board
|
|
443
445
|
Two concrete costs of making a `<tr>` draggable, both silent regressions on an ordinary data table:
|
|
444
446
|
|
|
445
447
|
1. **`draggable="true"` disables text selection inside the element** in every major browser. On a data table that quietly removes the ability to select and copy a cell value.
|
|
446
448
|
2. **`row_click_controller` makes the whole row the Show affordance.** A draggable row fights it: a drag that starts and ends in place still fires a click, and the user is navigated away instead of left where they were.
|
|
447
449
|
|
|
448
|
-
Neither applies to a kanban card
|
|
450
|
+
Neither applies to a kanban card: it has no cell text to select and no row-click behaviour, so the board keeps whole-card dragging, which is the better gesture where you can afford it. The inconsistency is deliberate.
|
|
449
451
|
|
|
450
452
|
A **grid** card gets a grip rather than whole-card dragging, because unlike a kanban card it *does* carry a row-click show affordance: reason 2 applies to it exactly as it does to a table row.
|
|
451
453
|
:::
|
|
452
454
|
|
|
453
455
|
---
|
|
454
456
|
|
|
455
|
-
## The endpoint
|
|
457
|
+
## The endpoint: `POST <member>/reposition` {#the-endpoint}
|
|
456
458
|
|
|
457
459
|
Mounted on every resource (like the kanban move routes). Resources that declare no `position_on` answer `404`.
|
|
458
460
|
|
|
@@ -461,19 +463,19 @@ POST /tasks/42/reposition?<the collection's own query string>
|
|
|
461
463
|
prev_id=41&next_id=43&to_index=2
|
|
462
464
|
```
|
|
463
465
|
|
|
464
|
-
The trailing query string is load-bearing, not decoration: it is the index's own query
|
|
466
|
+
The trailing query string is load-bearing, not decoration: it is the index's own query (search, filters, scope, sort, page, view), and it is what lets the endpoint re-render **exactly the page the user is looking at** through the ordinary index pipeline. Page 3 of a filtered list comes back as page 3 of that filtered list.
|
|
465
467
|
|
|
466
468
|
The client moves the row **optimistically**, so the response is deliberately quiet in the common case:
|
|
467
469
|
|
|
468
470
|
| Outcome | Response |
|
|
469
471
|
|---|---|
|
|
470
|
-
| Clean Mode A drop, both neighbours resolved | `204 No Content
|
|
472
|
+
| Clean Mode A drop, both neighbours resolved | `204 No Content`: nothing repaints |
|
|
471
473
|
| `reposition!` had to rebalance the group | `200` + turbo-stream of the collection |
|
|
472
|
-
| A neighbour did not resolve, or belongs to another positioning group, but the other one anchored the drop | `200` + stream
|
|
473
|
-
| Both neighbours rejected | `200` + stream, **no write
|
|
474
|
+
| A neighbour did not resolve, or belongs to another positioning group, but the other one anchored the drop | `200` + stream, the record **did** move, so there is nothing to explain |
|
|
475
|
+
| Both neighbours rejected | `200` + stream, **no write**: plus a toast when the cause was a foreign group rather than transient drift |
|
|
474
476
|
| Mode B (opaque block write) | `200` + stream |
|
|
475
477
|
| `reposition?` denied | `403` + stream + toast (the row snaps back) |
|
|
476
|
-
| `index?` denied | `403`, **no body
|
|
478
|
+
| `index?` denied | `403`, **no body**: you may not see the list, so the refusal must not carry it to you |
|
|
477
479
|
| Mode A drop under a foreign sort | `422` + stream + toast, **no write** |
|
|
478
480
|
| Validation failure, or the record was destroyed meanwhile | `422` + stream + toast |
|
|
479
481
|
| No `position_on`, Mode C, or the kanban view is selected | `404` |
|
|
@@ -489,7 +491,7 @@ end
|
|
|
489
491
|
|
|
490
492
|
Two checks run, in this order:
|
|
491
493
|
|
|
492
|
-
1. **`index?` on the resource class.** You must be able to *see* a list to reorder it
|
|
494
|
+
1. **`index?` on the resource class.** You must be able to *see* a list to reorder it; without this, a policy with `index? == false` but `update? == true` would be handed the whole listing by the reconciliation render.
|
|
493
495
|
2. **`reposition?` on the record.**
|
|
494
496
|
|
|
495
497
|
The same `reposition?` decides whether the grip renders at all, per row.
|
|
@@ -514,12 +516,12 @@ Each product's variants are numbered independently. A rebalance touches one prod
|
|
|
514
516
|
**Positioned globally, rendered nested:**
|
|
515
517
|
|
|
516
518
|
```ruby
|
|
517
|
-
positioned_on :position # scope: nil
|
|
519
|
+
positioned_on :position # scope: nil: one ordering across the whole table
|
|
518
520
|
```
|
|
519
521
|
|
|
520
|
-
Reordering within one parent still works correctly. Positions interleave across parents (product A holds `1.0, 3.0, 5.0` while product B holds `2.0, 4.0`), but because the list is ordered *by position*, the relative order inside A is exactly what the user sees and drags. And when a drop lands at the top or bottom of the visible page, the server looks up the real boundary neighbour in the model's group rather than taking `nil` at face value
|
|
522
|
+
Reordering within one parent still works correctly. Positions interleave across parents (product A holds `1.0, 3.0, 5.0` while product B holds `2.0, 4.0`), but because the list is ordered *by position*, the relative order inside A is exactly what the user sees and drags. And when a drop lands at the top or bottom of the visible page, the server looks up the real boundary neighbour in the model's group rather than taking `nil` at face value, which is what stops a bottom-of-page drop writing a position that duplicates a row the client could not see.
|
|
521
523
|
|
|
522
|
-
The one thing that leaks is a **rebalance**: gap exhaustion renumbers the whole *model-level* group, which with `scope: nil` is every row in the table, including other parents'. Positions stay in the same relative order, so nothing visibly moves
|
|
524
|
+
The one thing that leaks is a **rebalance**: gap exhaustion renumbers the whole *model-level* group, which with `scope: nil` is every row in the table, including other parents'. Positions stay in the same relative order, so nothing visibly moves, but far more rows are written than the user's gesture suggests. If a resource is normally viewed per-parent, scope it per-parent.
|
|
523
525
|
|
|
524
526
|
::: tip A neighbour from another group is drift, not an anchor
|
|
525
527
|
When the model *is* scoped, the endpoint rejects a neighbour id that resolves to a row in a **different** scope group and reconciles instead. This is not exotic: a `scope: :status` resource lists several groups in one table, with independent and freely interleaved numberings, so anchoring off a neighbour from another group would fling the record to an arbitrary point in its own.
|
|
@@ -529,12 +531,12 @@ When the model *is* scoped, the endpoint rejects a neighbour id that resolves to
|
|
|
529
531
|
|
|
530
532
|
## Hidden actions
|
|
531
533
|
|
|
532
|
-
`position_on` registers `action :reposition, hidden: true`. The flag is general
|
|
534
|
+
`position_on` registers `action :reposition, hidden: true`. The flag is general; see [Actions βΊ Hidden actions](/reference/resource/actions#hidden-actions).
|
|
533
535
|
|
|
534
536
|
A hidden action has a live route, a policy predicate, and (if interactive) the full form/params machinery. It simply renders in **no** toolbar, row dropdown, card, or bulk bar. It is how the framework exposes an endpoint reachable by a gesture rather than a button.
|
|
535
537
|
|
|
536
538
|
::: danger `hidden: true` is a display gate, NOT an authorization boundary
|
|
537
|
-
The route is live. Anyone who can construct the URL can `POST` to it. Authorization lives in the policy
|
|
539
|
+
The route is live. Anyone who can construct the URL can `POST` to it. Authorization lives in the policy (`def reposition?`) and runs whether or not anything rendered.
|
|
538
540
|
:::
|
|
539
541
|
|
|
540
542
|
---
|
|
@@ -548,22 +550,22 @@ The route is live. Anyone who can construct the URL can `POST` to it. Authorizat
|
|
|
548
550
|
| <kbd>β</kbd> | move the row/card one slot earlier |
|
|
549
551
|
| <kbd>β</kbd> | move it one slot later |
|
|
550
552
|
|
|
551
|
-
Focus travels with the row
|
|
553
|
+
Focus travels with the row, including across a rebalance, where the whole collection is replaced and focus is restored onto the same record's new grip.
|
|
552
554
|
|
|
553
555
|
Arrow navigation is deliberately **linear on a grid too**: <kbd>β</kbd> means the previous *card* in reading order, not the card one line above. A single position attribute stores a one-dimensional order, and two-dimensional navigation could not express the in-between slots at all.
|
|
554
556
|
|
|
555
557
|
::: warning Drag does not work on touch devices
|
|
556
558
|
The drag gesture uses **native HTML5 drag-and-drop**, which browsers do not fire from touch input. This limitation is inherited from the kanban board and applies identically here. Touch users cannot drag to reorder.
|
|
557
559
|
|
|
558
|
-
There is no automatic fallback. If touch reordering matters for your resource, expose an explicit ordering path
|
|
560
|
+
There is no automatic fallback. If touch reordering matters for your resource, expose an explicit ordering path: a "move up"/"move down" pair of [record actions](/reference/resource/actions), or a numeric position field on the edit form.
|
|
559
561
|
:::
|
|
560
562
|
|
|
561
563
|
---
|
|
562
564
|
|
|
563
565
|
## Related
|
|
564
566
|
|
|
565
|
-
- [Kanban βΊ Positioning](/reference/kanban/positioning)
|
|
566
|
-
- [Kanban βΊ DSL](/reference/kanban/dsl)
|
|
567
|
-
- [Actions](/reference/resource/actions)
|
|
568
|
-
- [Query](/reference/resource/query)
|
|
569
|
-
- [Nested resources](/reference/tenancy/nested-resources)
|
|
567
|
+
- [Kanban βΊ Positioning](/reference/kanban/positioning): the shared model API in full: arithmetic, `EPSILON`, rebalancing, `backfill_positions!`, the pure helpers
|
|
568
|
+
- [Kanban βΊ DSL](/reference/kanban/dsl): `position_on` inside `kanban doβ¦end`
|
|
569
|
+
- [Actions](/reference/resource/actions): `hidden:`, `condition:`, and the policy rule
|
|
570
|
+
- [Query](/reference/resource/query): `sort`, `default_sort`
|
|
571
|
+
- [Nested resources](/reference/tenancy/nested-resources): nested association tables
|
|
@@ -68,10 +68,10 @@ end
|
|
|
68
68
|
|
|
69
69
|
### Search powers typeahead too
|
|
70
70
|
|
|
71
|
-
The same `search` block drives **typeahead lookups** on association inputs that target this resource
|
|
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
72
|
|
|
73
73
|
::: tip Typeahead fallback when there's no search block
|
|
74
|
-
A resource without a `search` block still gets typeahead
|
|
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
75
|
|
|
76
76
|
1. The input's `label_method:` option, if it names a real column on the model.
|
|
77
77
|
2. Otherwise the first match from `[name, title, label, slug, display_name, email]`.
|
|
@@ -84,7 +84,7 @@ For large tables, write an explicit `search` block backed by a trigram or full-t
|
|
|
84
84
|
|
|
85
85
|
Six built-in filter types. Use the shorthand symbol or the full class name.
|
|
86
86
|
|
|
87
|
-
`search`, `scope`, `sort_fields`, and `sort_directions` are reserved filter names
|
|
87
|
+
`search`, `scope`, `sort_fields`, and `sort_directions` are reserved filter names; they're built-in controls in the `q[<name>]` namespace, so `filter :scope` raises `ArgumentError`.
|
|
88
88
|
|
|
89
89
|
| Type | Symbol | Params in URL | Options |
|
|
90
90
|
|---|---|---|---|
|
|
@@ -191,7 +191,7 @@ class PostDefinition < ResourceDefinition
|
|
|
191
191
|
scope :published # uses Post.published
|
|
192
192
|
scope :draft # uses Post.draft
|
|
193
193
|
|
|
194
|
-
# Inline scope
|
|
194
|
+
# Inline scope: block runs with the scope as argument
|
|
195
195
|
scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
|
|
196
196
|
scope(:this_month) { |s| s.where(created_at: Time.current.all_month) }
|
|
197
197
|
end
|
|
@@ -216,9 +216,9 @@ When a default is set:
|
|
|
216
216
|
- The default scope button is highlighted (not "All").
|
|
217
217
|
- Clicking "All" shows the unscoped collection.
|
|
218
218
|
|
|
219
|
-
### Conditional visibility
|
|
219
|
+
### Conditional visibility: `condition:`
|
|
220
220
|
|
|
221
|
-
Like `condition:` on [actions](./actions), a scope can be **defined but only render its button when a runtime proc is truthy**. The scope (and its URL) stays live either way
|
|
221
|
+
Like `condition:` on [actions](./actions), a scope can be **defined but only render its button when a runtime proc is truthy**. The scope (and its URL) stays live either way; `condition:` only toggles the button.
|
|
222
222
|
|
|
223
223
|
```ruby
|
|
224
224
|
scope :admin_only, condition: -> { current_user.admin? }
|
|
@@ -228,7 +228,7 @@ scope :beta_feature, condition: -> { params[:beta] == "1" }
|
|
|
228
228
|
scope :internal, condition: -> { false }
|
|
229
229
|
```
|
|
230
230
|
|
|
231
|
-
The proc is evaluated against the view context so `current_user`, `params`, `request`, and `allowed_to?` are all available directly. There is no `object`/`record
|
|
231
|
+
The proc is evaluated against the view context so `current_user`, `params`, `request`, and `allowed_to?` are all available directly. There is no `object`/`record`; scopes have no single-record context.
|
|
232
232
|
|
|
233
233
|
::: danger `condition:` is NOT authorization
|
|
234
234
|
A hidden scope button still has a **live URL** anyone can navigate to. `condition:` decides whether the *button renders*, not whether the *records are accessible*.
|
|
@@ -316,12 +316,12 @@ scope :this_month
|
|
|
316
316
|
- **Add indexes** for filtered and sorted columns.
|
|
317
317
|
- **Use `.distinct`** when joining associations in search to avoid duplicate rows.
|
|
318
318
|
- **Prefer scopes over filters** for queries used often (faster, no input parsing).
|
|
319
|
-
- **`pg_search` / FTS** for complex search
|
|
320
|
-
- **`LIKE '%q%'` can't use a b-tree index
|
|
319
|
+
- **`pg_search` / FTS** for complex search: write an explicit `search` block.
|
|
320
|
+
- **`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.
|
|
321
321
|
|
|
322
322
|
## Related
|
|
323
323
|
|
|
324
|
-
- [Definition](./definition)
|
|
325
|
-
- [Actions](./actions)
|
|
326
|
-
- [Behavior βΊ Policy](/reference/behavior/policies)
|
|
327
|
-
- [Tenancy βΊ Entity scoping](/reference/tenancy/entity-scoping)
|
|
324
|
+
- [Definition](./definition): field/input/display configuration
|
|
325
|
+
- [Actions](./actions): custom and bulk actions
|
|
326
|
+
- [Behavior βΊ Policy](/reference/behavior/policies): `relation_scope` (filters records to what the user can see)
|
|
327
|
+
- [Tenancy βΊ Entity scoping](/reference/tenancy/entity-scoping): multi-tenant filtering
|