plutonium 0.62.2 → 0.64.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 +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/.standard.yml +1 -1
- data/CHANGELOG.md +73 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +323 -141
- data/gemfiles/rails_8.0.gemfile.lock +126 -116
- data/gemfiles/rails_8.1.gemfile.lock +127 -117
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +20 -20
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +101 -22
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -0,0 +1,1787 @@
|
|
|
1
|
+
# Positioned Drag-and-Drop Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Give any Plutonium resource whose model is positioned native drag-to-reorder on its index table, nested association tables, and grid/card views — reusing kanban's positioning strategy machine and drag mechanics rather than duplicating them.
|
|
6
|
+
|
|
7
|
+
**Architecture:** `Plutonium::Kanban::Positioning::Config` is promoted to `Plutonium::Positioning::Config` so kanban's `position_on` and a new definition-level `position_on` DSL build the same three-mode strategy (delegate / block / disabled). A new `hidden: true` action flag generalises the existing one-off `kanban_drop?`, giving the reposition endpoint a route and policy predicate without rendering a button. Drag mechanics are extracted from `kanban_controller.js` into a shared `src/js/drag/sortable.js` consumed by both kanban and a new `positioned_controller.js`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby/Rails (Rails 7, 8.0, 8.1 via Appraisal), Minitest, Phlex components, Stimulus, native HTML5 drag-and-drop, Turbo Streams, TailwindCSS 4.
|
|
10
|
+
|
|
11
|
+
**User Verification:** NO — no user verification required. The spec asks for a framework feature; verification is by automated test plus a system test driving a real drag.
|
|
12
|
+
|
|
13
|
+
**Spec:** `docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md`
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## File Structure
|
|
18
|
+
|
|
19
|
+
**Created**
|
|
20
|
+
|
|
21
|
+
| File | Responsibility |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `lib/plutonium/positioning/config.rb` | The three-mode strategy machine + `Move` value object. Framework-wide, dependency-free. |
|
|
24
|
+
| `lib/plutonium/positioning/model.rb` | The ActiveRecord concern, moved out of the namespace so its constants stop leaking into user models. |
|
|
25
|
+
| `lib/plutonium/definition/positioning.rb` | The `position_on` definition DSL and its expansion. |
|
|
26
|
+
| `lib/plutonium/resource/controllers/position_actions.rb` | The `reposition` endpoint. |
|
|
27
|
+
| `lib/plutonium/ui/table/components/drag_handle.rb` | The grip Phlex component (table + grid). |
|
|
28
|
+
| `src/js/drag/sortable.js` | Shared native-DnD mechanics, no framework knowledge. |
|
|
29
|
+
| `src/js/controllers/positioned_controller.js` | Stimulus controller for tables/grids. |
|
|
30
|
+
| `test/plutonium/positioning/config_test.rb` | Config modes. |
|
|
31
|
+
| `test/plutonium/definition/positioning_test.rb` | `position_on` DSL expansion. |
|
|
32
|
+
| `test/plutonium/action/hidden_action_test.rb` | Hidden-action render exclusion. |
|
|
33
|
+
| `test/plutonium/resource/controllers/position_actions_test.rb` | Endpoint behaviour. |
|
|
34
|
+
| `test/system/positioned_drag_test.rb` | Real drag + keyboard reorder. |
|
|
35
|
+
|
|
36
|
+
**Modified**
|
|
37
|
+
|
|
38
|
+
| File | Change |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `lib/plutonium/positioning.rb` | Becomes a pure namespace (`EPSILON`, `.position_between`, `.gap_exhausted?`, `MigrationHelpers`); requires config + model. |
|
|
41
|
+
| `test/dummy/app/models/task.rb`, `kitchen_sink.rb`, `test/plutonium/positioning_test.rb`, `test/positioning_postgres_check.rb`, `docs/reference/kanban/{positioning,dsl}.md`, `docs/guides/kanban.md` | `include Plutonium::Positioning` → `::Model`. |
|
|
42
|
+
| `lib/plutonium/kanban/positioning.rb` | Becomes a thin alias to the promoted constants. |
|
|
43
|
+
| `lib/plutonium/action/base.rb:26,84,145` | `kanban_drop` → `hidden`. |
|
|
44
|
+
| `lib/plutonium/definition/index_views.rb:148` | Passes `hidden: true`. |
|
|
45
|
+
| `lib/plutonium/definition/base.rb:28-40` | `include Positioning`. |
|
|
46
|
+
| `lib/plutonium/ui/page/index.rb:36`, `page/show.rb:18`, `table/resource.rb:163`, `grid/card.rb:303` | `kanban_drop?` → `hidden?`. |
|
|
47
|
+
| `lib/plutonium/ui/table/resource.rb:189`, `grid/resource.rb:95` | Add the missing `!a.hidden?` filter. |
|
|
48
|
+
| `lib/plutonium/ui/table/resource.rb:~152` | Wrap the first column's block to emit the grip. |
|
|
49
|
+
| `lib/plutonium/resource/policy.rb` | Add `reposition?`. |
|
|
50
|
+
| `lib/plutonium/routing/mapper_extensions.rb:~155` | Add the `reposition` member route. |
|
|
51
|
+
| `src/js/controllers/kanban_controller.js` | Consumes `sortable.js`. |
|
|
52
|
+
| `src/js/controllers/register_controllers.js` | Register `positioned`. |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Task Ordering Rationale
|
|
57
|
+
|
|
58
|
+
Tasks 1–2 (Config promotion, rebalance signal) are pure refactors of existing tested code and land first so everything downstream builds on the final shapes. Task 3 (hidden actions) is independent and could run in parallel. Tasks 4–6 build the server feature. Tasks 7–10 build the client. Task 11 verifies end-to-end.
|
|
59
|
+
|
|
60
|
+
**Kanban is touched in Tasks 1, 4, and 6.** All three are behaviour-preserving, and kanban's existing suite is the gate for each:
|
|
61
|
+
|
|
62
|
+
| Task | Kanban change | Risk |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| 1 | `Positioning::Config` moves namespace, aliased back | Low — pure namespace move |
|
|
65
|
+
| 4 | `position_config` resolution becomes lazy via `position_config_for` | Low — mechanical, 5 call sites |
|
|
66
|
+
| 6 | Drag mechanics extracted to a shared module | **Highest** — touches drag behaviour itself |
|
|
67
|
+
|
|
68
|
+
Task 6 is the one to abandon first if it proves invasive; Tasks 1–5 stand without it (see spec Risks).
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### Task 1: Promote `Positioning::Config` out of the kanban namespace
|
|
73
|
+
|
|
74
|
+
**Goal:** One strategy machine, in `Plutonium::Positioning`, with kanban's public surface unchanged.
|
|
75
|
+
|
|
76
|
+
**Files:**
|
|
77
|
+
- Create: `lib/plutonium/positioning/config.rb`
|
|
78
|
+
- Modify: `lib/plutonium/kanban/positioning.rb` (becomes an alias)
|
|
79
|
+
- Modify: `lib/plutonium/positioning.rb` (require the new file)
|
|
80
|
+
- Test: `test/plutonium/positioning/config_test.rb` (new)
|
|
81
|
+
- Test: `test/plutonium/kanban/positioning_test.rb` (must pass unchanged)
|
|
82
|
+
|
|
83
|
+
**Acceptance Criteria:**
|
|
84
|
+
- [ ] `Plutonium::Positioning::Config` and `Plutonium::Positioning::Move` exist with the three modes
|
|
85
|
+
- [ ] `Plutonium::Kanban::Positioning::Config` still resolves, to the same object
|
|
86
|
+
- [ ] `Move` accepts `column:` and defaults it to `nil`
|
|
87
|
+
- [ ] `test/plutonium/kanban/positioning_test.rb` passes **unmodified**
|
|
88
|
+
|
|
89
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb` → all pass, 0 failures
|
|
90
|
+
|
|
91
|
+
**Steps:**
|
|
92
|
+
|
|
93
|
+
- [ ] **Step 1: Write the failing test for the promoted constants**
|
|
94
|
+
|
|
95
|
+
Create `test/plutonium/positioning/config_test.rb`:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# frozen_string_literal: true
|
|
99
|
+
|
|
100
|
+
require "test_helper"
|
|
101
|
+
|
|
102
|
+
module Plutonium
|
|
103
|
+
module Positioning
|
|
104
|
+
class ConfigTest < Minitest::Test
|
|
105
|
+
def test_kanban_namespace_still_resolves_to_the_promoted_class
|
|
106
|
+
assert_same Plutonium::Positioning::Config, Plutonium::Kanban::Positioning::Config
|
|
107
|
+
assert_same Plutonium::Positioning::Move, Plutonium::Kanban::Positioning::Move
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_move_column_defaults_to_nil_off_board
|
|
111
|
+
move = Plutonium::Positioning::Move.new(
|
|
112
|
+
record: :rec, prev: nil, next: nil, index: 0
|
|
113
|
+
)
|
|
114
|
+
assert_nil move.column
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_disabled_mode_leaves_the_relation_untouched
|
|
118
|
+
relation = Object.new
|
|
119
|
+
config = Plutonium::Positioning::Config.disabled
|
|
120
|
+
assert config.disabled?
|
|
121
|
+
assert_same relation, config.order(relation)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
129
|
+
|
|
130
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning/config_test.rb`
|
|
131
|
+
Expected: FAIL — `NameError: uninitialized constant Plutonium::Positioning::Config`
|
|
132
|
+
|
|
133
|
+
- [ ] **Step 3: Move the code**
|
|
134
|
+
|
|
135
|
+
Create `lib/plutonium/positioning/config.rb` containing the `Config` class and `Move` currently in `lib/plutonium/kanban/positioning.rb`, namespaced under `Plutonium::Positioning`. Two changes from the original:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# frozen_string_literal: true
|
|
139
|
+
|
|
140
|
+
module Plutonium
|
|
141
|
+
module Positioning
|
|
142
|
+
# Value object passed to Mode B blocks, carrying the full drop context.
|
|
143
|
+
Move = Data.define(:record, :column, :prev, :next, :index)
|
|
144
|
+
|
|
145
|
+
# ... Config class verbatim from lib/plutonium/kanban/positioning.rb,
|
|
146
|
+
# including .default / .attribute / .with_block / .disabled,
|
|
147
|
+
# #disabled? / #order / #reposition!
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Keep every comment from the original — they document the three modes and are the reason this class is comprehensible.
|
|
153
|
+
|
|
154
|
+
- [ ] **Step 4: Turn the kanban file into an alias**
|
|
155
|
+
|
|
156
|
+
Replace the body of `lib/plutonium/kanban/positioning.rb`:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# frozen_string_literal: true
|
|
160
|
+
|
|
161
|
+
require "plutonium/positioning"
|
|
162
|
+
|
|
163
|
+
module Plutonium
|
|
164
|
+
module Kanban
|
|
165
|
+
# Kanban's positioning strategy is the framework-wide one — see
|
|
166
|
+
# Plutonium::Positioning::Config. This alias preserves the original
|
|
167
|
+
# namespace so `position_on` and every existing reference keep working.
|
|
168
|
+
module Positioning
|
|
169
|
+
Config = Plutonium::Positioning::Config
|
|
170
|
+
Move = Plutonium::Positioning::Move
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- [ ] **Step 5: Require the config from the positioning concern**
|
|
177
|
+
|
|
178
|
+
At the top of `lib/plutonium/positioning.rb`, after the `frozen_string_literal` comment:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
require "plutonium/positioning/config"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
- [ ] **Step 6: Run both suites**
|
|
185
|
+
|
|
186
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning/config_test.rb`
|
|
187
|
+
Expected: PASS
|
|
188
|
+
|
|
189
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb`
|
|
190
|
+
Expected: PASS, **with the file unmodified** — this is what proves the move is behaviour-preserving.
|
|
191
|
+
|
|
192
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/dsl_test.rb`
|
|
193
|
+
Expected: PASS
|
|
194
|
+
|
|
195
|
+
- [ ] **Step 7: Commit**
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
git add lib/plutonium/positioning/config.rb lib/plutonium/positioning.rb \
|
|
199
|
+
lib/plutonium/kanban/positioning.rb test/plutonium/positioning/config_test.rb
|
|
200
|
+
git commit -m "refactor(positioning): promote Config out of the kanban namespace"
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
### Task 2: `reposition!` reports whether it rebalanced
|
|
206
|
+
|
|
207
|
+
**Goal:** The controller can tell a clean drop from one that renumbered the group, without re-deriving it.
|
|
208
|
+
|
|
209
|
+
**Files:**
|
|
210
|
+
- Modify: `lib/plutonium/positioning/model.rb:64-77` (`reposition!` — moved here in Task 1)
|
|
211
|
+
- Modify: `lib/plutonium/positioning/config.rb` (`#reposition!` return value)
|
|
212
|
+
- Test: `test/plutonium/positioning_test.rb`
|
|
213
|
+
- Test: `test/plutonium/positioning/config_test.rb`
|
|
214
|
+
|
|
215
|
+
**Acceptance Criteria:**
|
|
216
|
+
- [ ] `reposition!` returns an object responding to `rebalanced?`
|
|
217
|
+
- [ ] `rebalanced?` is `true` only when the gap was exhausted and the group was renumbered
|
|
218
|
+
- [ ] `Config#reposition!` returns `rebalanced?` in Mode A, `true` in Mode B, `false` in Mode C
|
|
219
|
+
- [ ] Existing positioning tests still pass
|
|
220
|
+
|
|
221
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb` → all pass
|
|
222
|
+
|
|
223
|
+
**Steps:**
|
|
224
|
+
|
|
225
|
+
- [ ] **Step 1: Write the failing tests**
|
|
226
|
+
|
|
227
|
+
Append to `test/plutonium/positioning_test.rb`, inside the existing class (it already builds `@item_class` on `positioning_test_items` with `positioned_on :position, scope: :status`):
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
def test_reposition_reports_no_rebalance_on_a_normal_move
|
|
231
|
+
a = @item_class.create!(status: "todo")
|
|
232
|
+
b = @item_class.create!(status: "todo")
|
|
233
|
+
c = @item_class.create!(status: "todo")
|
|
234
|
+
|
|
235
|
+
result = c.reposition!(prev_record: a, next_record: b)
|
|
236
|
+
|
|
237
|
+
refute result.rebalanced?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def test_reposition_reports_a_rebalance_when_the_gap_is_exhausted
|
|
241
|
+
a = @item_class.create!(status: "todo")
|
|
242
|
+
b = @item_class.create!(status: "todo")
|
|
243
|
+
c = @item_class.create!(status: "todo")
|
|
244
|
+
|
|
245
|
+
# Collapse the gap below EPSILON (1e-6) so the midpoint would collide.
|
|
246
|
+
a.update_column(:position, 1.0)
|
|
247
|
+
b.update_column(:position, 1.0 + 1e-9)
|
|
248
|
+
|
|
249
|
+
result = c.reposition!(prev_record: a.reload, next_record: b.reload)
|
|
250
|
+
|
|
251
|
+
assert result.rebalanced?
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
And in `test/plutonium/positioning/config_test.rb`:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
def test_block_mode_always_reports_rebalanced
|
|
259
|
+
# A Mode B block is an opaque write — the framework cannot know what it did,
|
|
260
|
+
# so it always forces reconciliation. See spec §3.1.
|
|
261
|
+
config = Plutonium::Positioning::Config.with_block(:position, ->(_move) { :ignored })
|
|
262
|
+
result = config.reposition!(
|
|
263
|
+
record: Object.new, prev_record: nil, next_record: nil, index: 0
|
|
264
|
+
)
|
|
265
|
+
assert result
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def test_disabled_mode_never_reports_rebalanced
|
|
269
|
+
config = Plutonium::Positioning::Config.disabled
|
|
270
|
+
result = config.reposition!(
|
|
271
|
+
record: Object.new, prev_record: nil, next_record: nil, index: 0
|
|
272
|
+
)
|
|
273
|
+
refute result
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
- [ ] **Step 2: Run and watch them fail**
|
|
278
|
+
|
|
279
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb -n /rebalance/`
|
|
280
|
+
Expected: FAIL — `NoMethodError: undefined method 'rebalanced?'`
|
|
281
|
+
|
|
282
|
+
- [ ] **Step 3: Return a result from `reposition!`**
|
|
283
|
+
|
|
284
|
+
In `lib/plutonium/positioning.rb`, add the result type to the namespace (alongside `EPSILON` and the module functions — NOT inside `Model`, so it does not enter user models' constant lookup):
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# Outcome of a #reposition! call. `rebalanced?` is true when the gap
|
|
288
|
+
# between the neighbours was exhausted and the whole scope group had to
|
|
289
|
+
# be renumbered — the caller needs to know because every other row's
|
|
290
|
+
# position changed, so any cached client-side view of the list is stale.
|
|
291
|
+
Result = Data.define(:rebalanced) do
|
|
292
|
+
def rebalanced? = rebalanced
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Then rewrite `reposition!` in `lib/plutonium/positioning/model.rb` (currently lines 64-77) to track and return it:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
def reposition!(prev_record:, next_record:)
|
|
300
|
+
col = self.class.positioning_column
|
|
301
|
+
prev_val = prev_record&.public_send(col)
|
|
302
|
+
next_val = next_record&.public_send(col)
|
|
303
|
+
rebalanced = false
|
|
304
|
+
if Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
|
|
305
|
+
rebalance_scope_group!
|
|
306
|
+
rebalanced = true
|
|
307
|
+
prev_val = prev_record&.reload&.public_send(col)
|
|
308
|
+
next_val = next_record&.reload&.public_send(col)
|
|
309
|
+
end
|
|
310
|
+
update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
|
|
311
|
+
Result.new(rebalanced:)
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 4: Surface it from `Config#reposition!`**
|
|
316
|
+
|
|
317
|
+
In `lib/plutonium/positioning/config.rb`, change `#reposition!` to return a boolean meaning "the caller must reconcile":
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
# Persist the new position for a dropped record. Returns true when the
|
|
321
|
+
# caller must reconcile its view of the list because positions other
|
|
322
|
+
# than this record's may have changed.
|
|
323
|
+
#
|
|
324
|
+
# Mode A: whether reposition! had to rebalance the scope group.
|
|
325
|
+
# Mode B: always true — the block is an opaque write and gems in this
|
|
326
|
+
# space routinely renumber the whole group (acts_as_list does).
|
|
327
|
+
# Mode C: always false — nothing was written.
|
|
328
|
+
def reposition!(record:, column:, prev_record:, next_record:, index:)
|
|
329
|
+
case @mode
|
|
330
|
+
when :delegate
|
|
331
|
+
record.reposition!(prev_record:, next_record:).rebalanced?
|
|
332
|
+
when :block
|
|
333
|
+
@block.call(Move.new(record:, column:, prev: prev_record, next: next_record, index:))
|
|
334
|
+
true
|
|
335
|
+
when :disabled
|
|
336
|
+
false
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
- [ ] **Step 5: Run the tests**
|
|
342
|
+
|
|
343
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb`
|
|
344
|
+
Expected: PASS
|
|
345
|
+
|
|
346
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning/config_test.rb`
|
|
347
|
+
Expected: PASS
|
|
348
|
+
|
|
349
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb`
|
|
350
|
+
Expected: PASS — kanban ignores the return value, so this must be unaffected.
|
|
351
|
+
|
|
352
|
+
- [ ] **Step 6: Commit**
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
git add lib/plutonium/positioning.rb lib/plutonium/positioning/config.rb \
|
|
356
|
+
test/plutonium/positioning_test.rb test/plutonium/positioning/config_test.rb
|
|
357
|
+
git commit -m "feat(positioning): report whether reposition! rebalanced the group"
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
### Task 3: Hidden actions
|
|
363
|
+
|
|
364
|
+
**Goal:** `hidden: true` is a general action flag; `kanban_drop` disappears; both bulk-action selectors stop leaking hidden actions.
|
|
365
|
+
|
|
366
|
+
**Files:**
|
|
367
|
+
- Modify: `lib/plutonium/action/base.rb:26,84,145`
|
|
368
|
+
- Modify: `lib/plutonium/definition/index_views.rb:148`
|
|
369
|
+
- Modify: `lib/plutonium/ui/page/index.rb:36`, `lib/plutonium/ui/page/show.rb:18`
|
|
370
|
+
- Modify: `lib/plutonium/ui/table/resource.rb:163` and `:189`
|
|
371
|
+
- Modify: `lib/plutonium/ui/grid/card.rb:303`, `lib/plutonium/ui/grid/resource.rb:95`
|
|
372
|
+
- Modify: `lib/plutonium/kanban/column.rb:32-40` (comment only)
|
|
373
|
+
- Test: `test/plutonium/action/hidden_action_test.rb` (new)
|
|
374
|
+
|
|
375
|
+
**Acceptance Criteria:**
|
|
376
|
+
- [ ] `Action::Base#hidden?` exists; `#kanban_drop?` is gone
|
|
377
|
+
- [ ] `hidden` round-trips through `#with` (it is in `to_options`)
|
|
378
|
+
- [ ] All six render-site selectors filter `!a.hidden?`
|
|
379
|
+
- [ ] Both bulk-action selectors filter it — the gap this closes
|
|
380
|
+
- [ ] Kanban's suite passes
|
|
381
|
+
|
|
382
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/hidden_action_test.rb` → all pass
|
|
383
|
+
|
|
384
|
+
**Steps:**
|
|
385
|
+
|
|
386
|
+
- [ ] **Step 1: Write the failing test**
|
|
387
|
+
|
|
388
|
+
Create `test/plutonium/action/hidden_action_test.rb`:
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
# frozen_string_literal: true
|
|
392
|
+
|
|
393
|
+
require "test_helper"
|
|
394
|
+
|
|
395
|
+
module Plutonium
|
|
396
|
+
module Action
|
|
397
|
+
class HiddenActionTest < Minitest::Test
|
|
398
|
+
def test_actions_are_visible_by_default
|
|
399
|
+
refute Plutonium::Action::Simple.new(:archive).hidden?
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def test_hidden_flag_is_readable
|
|
403
|
+
assert Plutonium::Action::Simple.new(:reposition, hidden: true).hidden?
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# `with` reconstructs an action from to_options — anything missing there
|
|
407
|
+
# is silently dropped on round-trip, which would un-hide the action.
|
|
408
|
+
def test_hidden_survives_a_with_round_trip
|
|
409
|
+
action = Plutonium::Action::Simple.new(:reposition, hidden: true)
|
|
410
|
+
assert action.with(label: "Move").hidden?
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def test_hidden_can_be_turned_off_via_with
|
|
414
|
+
action = Plutonium::Action::Simple.new(:reposition, hidden: true)
|
|
415
|
+
refute action.with(hidden: false).hidden?
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
423
|
+
|
|
424
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/hidden_action_test.rb`
|
|
425
|
+
Expected: FAIL — `NoMethodError: undefined method 'hidden?'`
|
|
426
|
+
|
|
427
|
+
- [ ] **Step 3: Rename the flag in `Action::Base`**
|
|
428
|
+
|
|
429
|
+
`lib/plutonium/action/base.rb` — line 26:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
@hidden = options[:hidden] || false
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Line 84, replacing `kanban_drop?` and its comment:
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
# True when this action must never render. It still has a live route,
|
|
439
|
+
# policy predicate, and (if interactive) form + params machinery — it is
|
|
440
|
+
# simply reachable only by something other than a button: a kanban drop,
|
|
441
|
+
# a drag-reorder, a custom Stimulus controller.
|
|
442
|
+
#
|
|
443
|
+
# Display-only, like #condition_met? — NOT an authorization boundary.
|
|
444
|
+
# Keep authorization in the policy.
|
|
445
|
+
def hidden? = @hidden
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Line 145, in `to_options`:
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
hidden: @hidden,
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
- [ ] **Step 4: Update the producer**
|
|
455
|
+
|
|
456
|
+
`lib/plutonium/definition/index_views.rb:148` — change `kanban_drop: true` to `hidden: true`, and update the comment on lines 138-139 from "It is flagged `kanban_drop: true`" to "It is flagged `hidden: true`".
|
|
457
|
+
|
|
458
|
+
- [ ] **Step 5: Update the four existing filter sites**
|
|
459
|
+
|
|
460
|
+
Replace `!a.kanban_drop?` with `!a.hidden?` in:
|
|
461
|
+
- `lib/plutonium/ui/page/index.rb:36`
|
|
462
|
+
- `lib/plutonium/ui/page/show.rb:18`
|
|
463
|
+
- `lib/plutonium/ui/table/resource.rb:163`
|
|
464
|
+
- `lib/plutonium/ui/grid/card.rb:303`
|
|
465
|
+
|
|
466
|
+
- [ ] **Step 6: Close the two bulk-action gaps**
|
|
467
|
+
|
|
468
|
+
`lib/plutonium/ui/grid/resource.rb:95`:
|
|
469
|
+
|
|
470
|
+
```ruby
|
|
471
|
+
.select { |k, a| a.bulk_action? && !a.hidden? }
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
`lib/plutonium/ui/table/resource.rb:189`:
|
|
475
|
+
|
|
476
|
+
```ruby
|
|
477
|
+
.select { |k, a| a.bulk_action? && !a.hidden? && a.condition_met?(view_context) }
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
- [ ] **Step 7: Downgrade the kanban guard comment**
|
|
481
|
+
|
|
482
|
+
In `lib/plutonium/kanban/column.rb`, the comment ending "...or (b) get auto-classified by Action::Interactive::Factory as a bulk action and leak into the bulk-actions bar (which does not filter kanban_drop actions)" is now stale — the bulk bar does filter. Replace that final clause with:
|
|
483
|
+
|
|
484
|
+
```ruby
|
|
485
|
+
# by Action::Interactive::Factory as a bulk action. The bulk bars now
|
|
486
|
+
# filter hidden actions, so this is defence in depth rather than the
|
|
487
|
+
# only guard — but a mis-shaped interaction would still fail confusingly
|
|
488
|
+
# at drop time, so reject it here where the error can name the cause.
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
- [ ] **Step 8: Verify no references remain**
|
|
492
|
+
|
|
493
|
+
Run: `rg -n "kanban_drop\b" lib app test`
|
|
494
|
+
Expected: **no output.** Any hit is a missed site — fix before continuing. (Note `kanban_drop_interaction` / `kanban_drop_immediate` / `kanban_drop_confirm` in `lib/plutonium/ui/kanban/column.rb` are unrelated *data attribute* names, not this flag — the `\b` in the pattern excludes them.)
|
|
495
|
+
|
|
496
|
+
- [ ] **Step 9: Run the tests**
|
|
497
|
+
|
|
498
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/hidden_action_test.rb`
|
|
499
|
+
Expected: PASS
|
|
500
|
+
|
|
501
|
+
Run: `bundle exec appraisal rails-8.1 rake test`
|
|
502
|
+
Expected: PASS — the full suite, because this task touched six render paths.
|
|
503
|
+
|
|
504
|
+
- [ ] **Step 10: Commit**
|
|
505
|
+
|
|
506
|
+
```bash
|
|
507
|
+
git add lib/plutonium/action/base.rb lib/plutonium/definition/index_views.rb \
|
|
508
|
+
lib/plutonium/ui/page/index.rb lib/plutonium/ui/page/show.rb \
|
|
509
|
+
lib/plutonium/ui/table/resource.rb lib/plutonium/ui/grid/card.rb \
|
|
510
|
+
lib/plutonium/ui/grid/resource.rb lib/plutonium/kanban/column.rb \
|
|
511
|
+
test/plutonium/action/hidden_action_test.rb
|
|
512
|
+
git commit -m "feat(actions): generalise kanban_drop into a hidden action flag"
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
### Task 4: The `position_on` definition DSL
|
|
518
|
+
|
|
519
|
+
**Goal:** `position_on` in a definition declares the resource drag-orderable and expands to sort + default_sort + a hidden reposition action.
|
|
520
|
+
|
|
521
|
+
**Files:**
|
|
522
|
+
- Create: `lib/plutonium/definition/positioning.rb`
|
|
523
|
+
- Modify: `lib/plutonium/definition/base.rb:28-40` (add `include Positioning`)
|
|
524
|
+
- Modify: `lib/plutonium/kanban/dsl.rb:18` (stop seeding a default)
|
|
525
|
+
- Modify: `lib/plutonium/kanban/board.rb:~37` (add `position_config_for`)
|
|
526
|
+
- Modify: `lib/plutonium/resource/controllers/kanban_actions.rb:136,259,581,733,784`
|
|
527
|
+
- Test: `test/plutonium/definition/positioning_test.rb` (new)
|
|
528
|
+
|
|
529
|
+
**Acceptance Criteria:**
|
|
530
|
+
- [ ] A kanban board inherits the definition's `position_on`; its own block overrides
|
|
531
|
+
- [ ] A board on a definition with no `position_on` still gets `Config.default`
|
|
532
|
+
- [ ] Resolution is lazy — declaration order in the class body does not matter
|
|
533
|
+
- [ ] `position_on` / `position_on :attr` / `position_on(:attr) { |move| }` / `position_on false` build the right Config
|
|
534
|
+
- [ ] Mode A raises at class-load time if the model lacks `Plutonium::Positioning`
|
|
535
|
+
- [ ] Mode B does **not** require the concern
|
|
536
|
+
- [ ] Modes A and B register `sort <attr>`, `default_sort <attr>, :asc`, and a hidden `:reposition` action
|
|
537
|
+
- [ ] Mode C registers none of them
|
|
538
|
+
- [ ] `defined_position_config` is readable from an instance
|
|
539
|
+
|
|
540
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb` → all pass
|
|
541
|
+
|
|
542
|
+
**Steps:**
|
|
543
|
+
|
|
544
|
+
- [ ] **Step 1: Write the failing tests**
|
|
545
|
+
|
|
546
|
+
Create `test/plutonium/definition/positioning_test.rb`. `Task` in the dummy app is already `positioned_on :position, scope: :status`, so it is the Mode A subject; `Comment` is not positioned, so it is the negative case.
|
|
547
|
+
|
|
548
|
+
```ruby
|
|
549
|
+
# frozen_string_literal: true
|
|
550
|
+
|
|
551
|
+
require "test_helper"
|
|
552
|
+
|
|
553
|
+
module Plutonium
|
|
554
|
+
module Definition
|
|
555
|
+
class PositioningTest < Minitest::Test
|
|
556
|
+
def build_definition(model, &block)
|
|
557
|
+
Class.new(Plutonium::Resource::Definition) do
|
|
558
|
+
define_singleton_method(:model_class) { model }
|
|
559
|
+
class_eval(&block) if block
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def test_bare_positioned_defaults_to_the_position_attribute
|
|
564
|
+
definition = build_definition(::Task) { position_on }
|
|
565
|
+
config = definition.new.defined_position_config
|
|
566
|
+
|
|
567
|
+
assert_equal :position, config.attribute
|
|
568
|
+
refute config.disabled?
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def test_custom_attribute
|
|
572
|
+
definition = build_definition(::Task) { position_on :sort_order }
|
|
573
|
+
assert_equal :sort_order, definition.new.defined_position_config.attribute
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def test_positioned_sets_the_default_sort_and_registers_the_sort
|
|
577
|
+
definition = build_definition(::Task) { position_on }
|
|
578
|
+
|
|
579
|
+
assert_equal [:position, :asc], definition._default_sort
|
|
580
|
+
assert_includes definition.defined_sorts.keys, :position
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def test_positioned_registers_a_hidden_reposition_action
|
|
584
|
+
definition = build_definition(::Task) { position_on }
|
|
585
|
+
action = definition.defined_actions[:reposition]
|
|
586
|
+
|
|
587
|
+
refute_nil action
|
|
588
|
+
assert action.hidden?
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Mode A delegates to record.reposition!, which only exists on models that
|
|
592
|
+
# include the concern. Failing at class-load time beats a 500 on first drag.
|
|
593
|
+
def test_mode_a_requires_the_model_to_include_the_concern
|
|
594
|
+
error = assert_raises(ArgumentError) do
|
|
595
|
+
build_definition(::Comment) { position_on }
|
|
596
|
+
end
|
|
597
|
+
assert_match(/Plutonium::Positioning/, error.message)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Mode B never calls reposition!, so the concern is irrelevant.
|
|
601
|
+
def test_mode_b_does_not_require_the_concern
|
|
602
|
+
definition = build_definition(::Comment) do
|
|
603
|
+
position_on(:rank) { |move| move.record.insert_at(move.index + 1) }
|
|
604
|
+
end
|
|
605
|
+
assert_equal :rank, definition.new.defined_position_config.attribute
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def test_mode_c_registers_nothing
|
|
609
|
+
definition = build_definition(::Task) { position_on false }
|
|
610
|
+
|
|
611
|
+
assert definition.new.defined_position_config.disabled?
|
|
612
|
+
assert_nil definition.defined_actions[:reposition]
|
|
613
|
+
refute_equal [:position, :asc], definition._default_sort
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def test_default_sort_can_be_overridden_after_positioned
|
|
617
|
+
definition = build_definition(::Task) do
|
|
618
|
+
position_on
|
|
619
|
+
default_sort :name, :asc
|
|
620
|
+
end
|
|
621
|
+
assert_equal [:name, :asc], definition._default_sort
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
- [ ] **Step 2: Run and watch them fail**
|
|
629
|
+
|
|
630
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb`
|
|
631
|
+
Expected: FAIL — `NoMethodError: undefined method `position_on``
|
|
632
|
+
|
|
633
|
+
- [ ] **Step 3: Write the concern**
|
|
634
|
+
|
|
635
|
+
Create `lib/plutonium/definition/positioning.rb`:
|
|
636
|
+
|
|
637
|
+
```ruby
|
|
638
|
+
# frozen_string_literal: true
|
|
639
|
+
|
|
640
|
+
require "plutonium/positioning/config"
|
|
641
|
+
|
|
642
|
+
module Plutonium
|
|
643
|
+
module Definition
|
|
644
|
+
# Declares a resource drag-orderable. The MODEL owns how positions are
|
|
645
|
+
# stored (`positioned_on :position, scope: :project_id`); this only says
|
|
646
|
+
# "this UI can be reordered", and never restates the column or the scope.
|
|
647
|
+
#
|
|
648
|
+
# position_on # Mode A, attribute :position
|
|
649
|
+
# position_on :sort_order # Mode A, custom attribute
|
|
650
|
+
# position_on(:rank) { |move| … } # Mode B, another gem owns the write
|
|
651
|
+
# position_on false # Mode C, ordering off
|
|
652
|
+
#
|
|
653
|
+
# Mirrors kanban's `position_on` (lib/plutonium/kanban/dsl.rb:46) exactly,
|
|
654
|
+
# and builds the same Plutonium::Positioning::Config.
|
|
655
|
+
module Positioning
|
|
656
|
+
extend ActiveSupport::Concern
|
|
657
|
+
|
|
658
|
+
included do
|
|
659
|
+
class_attribute :defined_position_config, instance_writer: false,
|
|
660
|
+
instance_predicate: false, default: nil
|
|
661
|
+
|
|
662
|
+
def self.position_on(attribute = :position, &block)
|
|
663
|
+
config =
|
|
664
|
+
if attribute == false
|
|
665
|
+
Plutonium::Positioning::Config.disabled
|
|
666
|
+
elsif block
|
|
667
|
+
Plutonium::Positioning::Config.with_block(attribute, block)
|
|
668
|
+
else
|
|
669
|
+
validate_model_is_positioned!(attribute)
|
|
670
|
+
Plutonium::Positioning::Config.attribute(attribute)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
self.defined_position_config = config
|
|
674
|
+
return config if config.disabled?
|
|
675
|
+
|
|
676
|
+
# Registering the sort is load-bearing: dragging is only permitted
|
|
677
|
+
# when the collection is ordered by this attribute, so without a
|
|
678
|
+
# permitted sort there is no way back out of the disabled state.
|
|
679
|
+
sort config.attribute
|
|
680
|
+
default_sort config.attribute, :asc
|
|
681
|
+
|
|
682
|
+
# Hidden: it has a route and a `reposition?` policy predicate, but is
|
|
683
|
+
# reachable only by dragging — never rendered as a button.
|
|
684
|
+
action :reposition, hidden: true
|
|
685
|
+
|
|
686
|
+
config
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Mode A calls record.reposition! AND reads `.rebalanced?` off its
|
|
690
|
+
# return, so it needs the real concern — not merely something that
|
|
691
|
+
# responds to reposition!. A model that hand-rolls reposition! (wrapping
|
|
692
|
+
# acts_as_list, say) would otherwise fail at drop time, mid-transaction,
|
|
693
|
+
# with `NoMethodError: undefined method 'rebalanced?' for true`.
|
|
694
|
+
#
|
|
695
|
+
# This check lives HERE rather than in Config#reposition! for two
|
|
696
|
+
# reasons: the house rule against defensive call-site guards, and
|
|
697
|
+
# config.rb's standalone-loadability invariant — naming
|
|
698
|
+
# Plutonium::Positioning::Model there would pull in the concern and
|
|
699
|
+
# break the guard test. The DSL is where the mode is chosen, so it is
|
|
700
|
+
# where the contract belongs.
|
|
701
|
+
def self.validate_model_is_positioned!(attribute)
|
|
702
|
+
return if model_class.include?(Plutonium::Positioning::Model)
|
|
703
|
+
|
|
704
|
+
raise ArgumentError,
|
|
705
|
+
"#{name || "definition"}: `position_on #{attribute.inspect}` requires " \
|
|
706
|
+
"#{model_class} to `include Plutonium::Positioning::Model` and declare " \
|
|
707
|
+
"`positioned_on`. If another gem owns positioning for this model, " \
|
|
708
|
+
"use the block form instead: position_on(#{attribute.inspect}) { |move| … }"
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def defined_position_config = self.class.defined_position_config
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
- [ ] **Step 4: Include it in the definition base**
|
|
719
|
+
|
|
720
|
+
In `lib/plutonium/definition/base.rb`, add to the include list (lines 28-40). It must come **after** `Actions` and `Sorting`, because `position_on` calls `action`, `sort`, and `default_sort`:
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
include Actions
|
|
724
|
+
include Wizards
|
|
725
|
+
include Sorting
|
|
726
|
+
include Positioning
|
|
727
|
+
include Scoping
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
- [ ] **Step 5: Write the failing board-inheritance test**
|
|
731
|
+
|
|
732
|
+
A board must inherit the definition's `position_on`, with its own block overriding. Append to `test/plutonium/definition/positioning_test.rb`:
|
|
733
|
+
|
|
734
|
+
```ruby
|
|
735
|
+
def test_board_inherits_the_definitions_position_on
|
|
736
|
+
definition = build_definition(::Task) do
|
|
737
|
+
position_on :sort_order
|
|
738
|
+
kanban { columns :todo, :done }
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
board = definition.defined_kanban_board
|
|
742
|
+
config = board.position_config_for(definition.new)
|
|
743
|
+
|
|
744
|
+
assert_equal :sort_order, config.attribute
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def test_a_board_block_overrides_the_definition
|
|
748
|
+
definition = build_definition(::Task) do
|
|
749
|
+
position_on :sort_order
|
|
750
|
+
kanban do
|
|
751
|
+
columns :todo, :done
|
|
752
|
+
position_on :board_rank
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
board = definition.defined_kanban_board
|
|
757
|
+
assert_equal :board_rank, board.position_config_for(definition.new).attribute
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# A board on a definition with no position_on keeps the historic default.
|
|
761
|
+
def test_board_falls_back_to_the_default_when_nothing_is_declared
|
|
762
|
+
definition = build_definition(::Task) { kanban { columns :todo, :done } }
|
|
763
|
+
config = definition.defined_kanban_board.position_config_for(definition.new)
|
|
764
|
+
|
|
765
|
+
assert_equal :position, config.attribute
|
|
766
|
+
refute config.disabled?
|
|
767
|
+
end
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb -n /board/`
|
|
771
|
+
Expected: FAIL — `NoMethodError: undefined method 'position_config_for'`
|
|
772
|
+
|
|
773
|
+
- [ ] **Step 6: Stop the DSL seeding a default**
|
|
774
|
+
|
|
775
|
+
`lib/plutonium/kanban/dsl.rb:18` currently reads `@position_config = Positioning::Config.default`, which makes "not declared" indistinguishable from "declared as the default" — inheritance is impossible while that holds. Change it to:
|
|
776
|
+
|
|
777
|
+
```ruby
|
|
778
|
+
# nil means "not declared" so the board can inherit the definition's
|
|
779
|
+
# position_on. Resolution — including the historic Config.default
|
|
780
|
+
# fallback — moves to Board#position_config_for.
|
|
781
|
+
@position_config = nil
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
- [ ] **Step 7: Resolve lazily on the Board**
|
|
785
|
+
|
|
786
|
+
In `lib/plutonium/kanban/board.rb`, beside `show_in_for` (line 37), which this deliberately mirrors:
|
|
787
|
+
|
|
788
|
+
```ruby
|
|
789
|
+
# The board's positioning strategy: its own `position_on` if the block
|
|
790
|
+
# declared one, else the definition's, else the historic default.
|
|
791
|
+
#
|
|
792
|
+
# Resolved LAZILY, not at build time: `kanban` eagerly compiles the board
|
|
793
|
+
# at class-load (definition/index_views.rb:121), so a board built before a
|
|
794
|
+
# later `position_on` line would silently miss it — making the declaration
|
|
795
|
+
# order-dependent.
|
|
796
|
+
def position_config_for(definition)
|
|
797
|
+
@position_config || definition.defined_position_config ||
|
|
798
|
+
Plutonium::Positioning::Config.default
|
|
799
|
+
end
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
Keep `attr_reader :position_config` (line 6) — it is the "did this block declare one" accessor that `position_config_for` reads.
|
|
803
|
+
|
|
804
|
+
- [ ] **Step 8: Update the five kanban call sites**
|
|
805
|
+
|
|
806
|
+
In `lib/plutonium/resource/controllers/kanban_actions.rb`, replace `board.position_config` with `board.position_config_for(current_definition)` at lines 136, 259, 581, 733, and 784.
|
|
807
|
+
|
|
808
|
+
Verify none were missed:
|
|
809
|
+
|
|
810
|
+
Run: `rg -n "position_config\b" lib/plutonium/resource/controllers/kanban_actions.rb`
|
|
811
|
+
Expected: every hit is `position_config_for(current_definition)` — a bare `board.position_config` remaining is a bug.
|
|
812
|
+
|
|
813
|
+
- [ ] **Step 9: Run the tests**
|
|
814
|
+
|
|
815
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb`
|
|
816
|
+
Expected: PASS
|
|
817
|
+
|
|
818
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/base_test.rb`
|
|
819
|
+
Expected: PASS
|
|
820
|
+
|
|
821
|
+
Run: `bundle exec appraisal rails-8.1 rake test`
|
|
822
|
+
Expected: PASS — the kanban suite is the gate for the Board change.
|
|
823
|
+
|
|
824
|
+
- [ ] **Step 10: Commit**
|
|
825
|
+
|
|
826
|
+
```bash
|
|
827
|
+
git add lib/plutonium/definition/positioning.rb lib/plutonium/definition/base.rb \
|
|
828
|
+
lib/plutonium/kanban/dsl.rb lib/plutonium/kanban/board.rb \
|
|
829
|
+
lib/plutonium/resource/controllers/kanban_actions.rb \
|
|
830
|
+
test/plutonium/definition/positioning_test.rb
|
|
831
|
+
git commit -m "feat(definition): add the position_on DSL, inherited by kanban boards"
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
---
|
|
835
|
+
|
|
836
|
+
### Task 5: The reposition endpoint
|
|
837
|
+
|
|
838
|
+
**Goal:** `POST <member>/reposition` moves a record between two neighbours, authorized and scoped, responding per the reconciliation rules.
|
|
839
|
+
|
|
840
|
+
**Files:**
|
|
841
|
+
- Create: `lib/plutonium/resource/controllers/position_actions.rb`
|
|
842
|
+
- Modify: `lib/plutonium/routing/mapper_extensions.rb:~155`
|
|
843
|
+
- Modify: `lib/plutonium/resource/policy.rb` (add `reposition?`)
|
|
844
|
+
- Modify: `lib/plutonium/resource/controller.rb` (include the concern)
|
|
845
|
+
- Test: `test/plutonium/resource/controllers/position_actions_test.rb` (new)
|
|
846
|
+
|
|
847
|
+
**Acceptance Criteria:**
|
|
848
|
+
- [ ] `POST <member>/reposition` with `{prev_id, next_id}` repositions the record
|
|
849
|
+
- [ ] `reposition?` defaults to `update?`
|
|
850
|
+
- [ ] Neighbours resolve within `current_authorized_scope` only
|
|
851
|
+
- [ ] Clean Mode A drop → `204`; rebalance or unresolvable neighbour → turbo-stream tbody
|
|
852
|
+
- [ ] Mode B → always turbo-stream
|
|
853
|
+
- [ ] Denial → `403` + tbody + toast; `RecordNotFound` / `RecordInvalid` → `422` + tbody + toast
|
|
854
|
+
- [ ] A resource without `position_on` returns `404`
|
|
855
|
+
|
|
856
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/controllers/position_actions_test.rb` → all pass
|
|
857
|
+
|
|
858
|
+
**Steps:**
|
|
859
|
+
|
|
860
|
+
- [ ] **Step 1: Add the policy predicate**
|
|
861
|
+
|
|
862
|
+
In `lib/plutonium/resource/policy.rb`, next to `kanban_move?` (line 185):
|
|
863
|
+
|
|
864
|
+
```ruby
|
|
865
|
+
# Authorizes a drag-reorder. Delegates to update? by default — override to
|
|
866
|
+
# allow reordering without granting full edit-form access.
|
|
867
|
+
#
|
|
868
|
+
# @return [Boolean] Delegates to update?.
|
|
869
|
+
def reposition?
|
|
870
|
+
update?
|
|
871
|
+
end
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
- [ ] **Step 2: Add the route**
|
|
875
|
+
|
|
876
|
+
In `lib/plutonium/routing/mapper_extensions.rb`, inside `define_member_interactive_actions`, after the kanban lines (155):
|
|
877
|
+
|
|
878
|
+
```ruby
|
|
879
|
+
post "reposition", action: :reposition, as: :reposition
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
- [ ] **Step 3: Write the failing tests**
|
|
883
|
+
|
|
884
|
+
Create `test/plutonium/resource/controllers/position_actions_test.rb`. Follow the existing controller-test conventions in `test/plutonium/resource/` — read a neighbouring controller test first and match its setup (portal mounting, sign-in helper, `Task` fixtures).
|
|
885
|
+
|
|
886
|
+
Cover, at minimum:
|
|
887
|
+
|
|
888
|
+
```ruby
|
|
889
|
+
def test_reposition_moves_the_record_between_its_neighbours
|
|
890
|
+
a, b, c = three_tasks_in_position_order
|
|
891
|
+
|
|
892
|
+
post reposition_task_path(c), params: {prev_id: a.id, next_id: b.id}
|
|
893
|
+
|
|
894
|
+
assert_response :no_content
|
|
895
|
+
assert_operator a.reload.position, :<, c.reload.position
|
|
896
|
+
assert_operator c.reload.position, :<, b.reload.position
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def test_clean_drop_returns_204_with_no_body
|
|
900
|
+
a, b, c = three_tasks_in_position_order
|
|
901
|
+
post reposition_task_path(c), params: {prev_id: a.id, next_id: b.id}
|
|
902
|
+
|
|
903
|
+
assert_response :no_content
|
|
904
|
+
assert_empty response.body
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def test_a_rebalance_streams_the_tbody
|
|
908
|
+
a, b, c = three_tasks_in_position_order
|
|
909
|
+
a.update_column(:position, 1.0)
|
|
910
|
+
b.update_column(:position, 1.0 + 1e-9)
|
|
911
|
+
|
|
912
|
+
post reposition_task_path(c), params: {prev_id: a.id, next_id: b.id}
|
|
913
|
+
|
|
914
|
+
assert_response :success
|
|
915
|
+
assert_match "turbo-stream", response.body
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# A neighbour id outside the authorized scope must NOT silently become nil —
|
|
919
|
+
# that would mean "drop at the end", a different and wrong outcome.
|
|
920
|
+
def test_a_neighbour_outside_the_authorized_scope_reconciles_instead_of_appending
|
|
921
|
+
c = task_in_scope
|
|
922
|
+
foreign = task_outside_scope
|
|
923
|
+
|
|
924
|
+
post reposition_task_path(c), params: {prev_id: foreign.id, next_id: nil}
|
|
925
|
+
|
|
926
|
+
assert_response :success
|
|
927
|
+
assert_match "turbo-stream", response.body
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def test_denied_reposition_returns_403_and_snaps_back
|
|
931
|
+
# policy stubbed so reposition? is false
|
|
932
|
+
assert_response :forbidden
|
|
933
|
+
assert_match "turbo-stream", response.body
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def test_a_resource_without_positioned_returns_404
|
|
937
|
+
post reposition_comment_path(comment), params: {prev_id: nil, next_id: nil}
|
|
938
|
+
assert_response :not_found
|
|
939
|
+
end
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
- [ ] **Step 4: Run and watch them fail**
|
|
943
|
+
|
|
944
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/controllers/position_actions_test.rb`
|
|
945
|
+
Expected: FAIL — no `reposition` action
|
|
946
|
+
|
|
947
|
+
- [ ] **Step 5: Write the concern**
|
|
948
|
+
|
|
949
|
+
Create `lib/plutonium/resource/controllers/position_actions.rb`. The rescue clauses mirror `KanbanActions` (`kanban_actions.rb:331-372`) and each encodes a real lesson — keep the comments.
|
|
950
|
+
|
|
951
|
+
```ruby
|
|
952
|
+
# frozen_string_literal: true
|
|
953
|
+
|
|
954
|
+
module Plutonium
|
|
955
|
+
module Resource
|
|
956
|
+
module Controllers
|
|
957
|
+
# Drag-reorder endpoint for resources whose definition declares `position_on`.
|
|
958
|
+
#
|
|
959
|
+
# POST <member>/reposition with {prev_id:, next_id:} — the ids of the
|
|
960
|
+
# dropped row's visible neighbours, either nullable for a drop at an end.
|
|
961
|
+
#
|
|
962
|
+
# The client has already moved the row optimistically, so a clean drop
|
|
963
|
+
# answers 204. The tbody is streamed back only when the client's view is
|
|
964
|
+
# or may be stale: a rebalance renumbered the group, a neighbour did not
|
|
965
|
+
# resolve, or the write was a Mode B block (opaque — see spec §3.1).
|
|
966
|
+
module PositionActions
|
|
967
|
+
extend ActiveSupport::Concern
|
|
968
|
+
|
|
969
|
+
def reposition
|
|
970
|
+
config = current_definition.defined_position_config
|
|
971
|
+
|
|
972
|
+
if config.nil? || config.disabled?
|
|
973
|
+
# Not a reorderable resource — a 404, not an authorization failure.
|
|
974
|
+
skip_verify_authorize_current!
|
|
975
|
+
head :not_found
|
|
976
|
+
return
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
record = current_authorized_scope.find(params[:id])
|
|
980
|
+
authorize_current! record, to: :reposition?
|
|
981
|
+
|
|
982
|
+
# Resolve neighbours WITHIN the authorized scope. A nil here would mean
|
|
983
|
+
# "drop at the end", so an id that does not resolve must be treated as
|
|
984
|
+
# drift and reconciled — never silently coerced to nil.
|
|
985
|
+
prev_record, prev_ok = resolve_position_neighbour(params[:prev_id])
|
|
986
|
+
next_record, next_ok = resolve_position_neighbour(params[:next_id])
|
|
987
|
+
|
|
988
|
+
# Bind the return rather than branching on the call directly:
|
|
989
|
+
# `if config.reposition!(...)` reads as a success check, which this is
|
|
990
|
+
# NOT — failure raises. The boolean means "positions other than this
|
|
991
|
+
# record's may have changed, so the client's optimistic view is stale".
|
|
992
|
+
must_reconcile = config.reposition!(
|
|
993
|
+
record:,
|
|
994
|
+
prev_record:,
|
|
995
|
+
next_record:,
|
|
996
|
+
index: params[:to_index].to_i
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
if must_reconcile || !prev_ok || !next_ok
|
|
1000
|
+
render_position_reconciliation
|
|
1001
|
+
else
|
|
1002
|
+
head :no_content
|
|
1003
|
+
end
|
|
1004
|
+
rescue ::ActionPolicy::Unauthorized
|
|
1005
|
+
# NOTE: the leading :: is required — Plutonium::ActionPolicy exists, so a
|
|
1006
|
+
# bare ActionPolicy resolves to that namespace and never matches, letting
|
|
1007
|
+
# the exception reach the global rescue_from, which re-raises for
|
|
1008
|
+
# turbo_stream requests → an HTML error page morphed into the table.
|
|
1009
|
+
#
|
|
1010
|
+
# authorize_count only bumps after a SUCCESSFUL authorize, so a denial
|
|
1011
|
+
# leaves the verifier unsatisfied; we handled authorization by rejecting.
|
|
1012
|
+
skip_verify_authorize_current!
|
|
1013
|
+
render_position_reconciliation(
|
|
1014
|
+
reason: "You are not authorized to reorder this.",
|
|
1015
|
+
status: :forbidden
|
|
1016
|
+
)
|
|
1017
|
+
rescue ActiveRecord::RecordNotFound
|
|
1018
|
+
# The row was destroyed between render and drop. `find` raised before
|
|
1019
|
+
# authorize_current!, so satisfy that verifier explicitly.
|
|
1020
|
+
skip_verify_authorize_current!
|
|
1021
|
+
render_position_reconciliation(reason: "This record no longer exists.")
|
|
1022
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
1023
|
+
reason = e.record.errors.full_messages.to_sentence.presence ||
|
|
1024
|
+
"This record could not be moved."
|
|
1025
|
+
render_position_reconciliation(reason:)
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
private
|
|
1029
|
+
|
|
1030
|
+
# Returns [record, resolved?]. A blank id is a legitimate end-of-list
|
|
1031
|
+
# drop → [nil, true]. An id that does not resolve in the authorized
|
|
1032
|
+
# scope is drift → [nil, false], which forces reconciliation.
|
|
1033
|
+
def resolve_position_neighbour(id)
|
|
1034
|
+
return [nil, true] if id.blank?
|
|
1035
|
+
record = current_authorized_scope.find_by(id: id)
|
|
1036
|
+
[record, !record.nil?]
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# Re-renders the collection so the client's optimistic DOM is replaced by
|
|
1040
|
+
# the server's truth — the row snaps back on rejection, or settles into
|
|
1041
|
+
# its true place after a rebalance.
|
|
1042
|
+
def render_position_reconciliation(reason: nil, status: :ok)
|
|
1043
|
+
streams = [turbo_stream.update(position_collection_frame_id, render_position_collection_html)]
|
|
1044
|
+
|
|
1045
|
+
if reason
|
|
1046
|
+
streams << turbo_stream.append(
|
|
1047
|
+
"position-flash",
|
|
1048
|
+
partial: "plutonium/toast",
|
|
1049
|
+
locals: {type: :warning, msg: reason}
|
|
1050
|
+
)
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
render turbo_stream: streams, status:
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
**Note for the implementer:** `position_collection_frame_id` and `render_position_collection_html` are the two methods that must bind this concern to the actual table rendering. Locate how `CrudActions#index` builds the collection component (`lib/plutonium/resource/controllers/crud_actions.rb`) and reuse that path — do **not** invent a parallel rendering route. Model them on `KanbanActions#render_kanban_column_html` (`kanban_actions.rb:568`), which resolves the same query pipeline and renders one component to an HTML-safe string.
|
|
1062
|
+
|
|
1063
|
+
- [ ] **Step 6: Include the concern**
|
|
1064
|
+
|
|
1065
|
+
In `lib/plutonium/resource/controller.rb`, include `PositionActions` alongside the other controller concerns. Match the existing include order and style — check where `KanbanActions` is included and put this next to it.
|
|
1066
|
+
|
|
1067
|
+
- [ ] **Step 7: Run the tests**
|
|
1068
|
+
|
|
1069
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/controllers/position_actions_test.rb`
|
|
1070
|
+
Expected: PASS
|
|
1071
|
+
|
|
1072
|
+
Run: `bundle exec appraisal rails-8.1 rake test`
|
|
1073
|
+
Expected: PASS
|
|
1074
|
+
|
|
1075
|
+
- [ ] **Step 8: Commit**
|
|
1076
|
+
|
|
1077
|
+
```bash
|
|
1078
|
+
git add lib/plutonium/resource/controllers/position_actions.rb \
|
|
1079
|
+
lib/plutonium/routing/mapper_extensions.rb lib/plutonium/resource/policy.rb \
|
|
1080
|
+
lib/plutonium/resource/controller.rb \
|
|
1081
|
+
test/plutonium/resource/controllers/position_actions_test.rb
|
|
1082
|
+
git commit -m "feat(positioning): add the reposition endpoint"
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
---
|
|
1086
|
+
|
|
1087
|
+
### Task 6: Extract the shared drag core
|
|
1088
|
+
|
|
1089
|
+
**Goal:** One native-DnD implementation, in `src/js/drag/sortable.js`, with kanban's behaviour unchanged.
|
|
1090
|
+
|
|
1091
|
+
**Files:**
|
|
1092
|
+
- Create: `src/js/drag/sortable.js`
|
|
1093
|
+
- Modify: `src/js/controllers/kanban_controller.js:433-523, 700-706`
|
|
1094
|
+
|
|
1095
|
+
**Acceptance Criteria:**
|
|
1096
|
+
- [ ] `sortable.js` exports the drag mechanics with no kanban/board/column knowledge
|
|
1097
|
+
- [ ] `kanban_controller.js` consumes it; its drag behaviour is byte-for-byte equivalent
|
|
1098
|
+
- [ ] Kanban's suite passes unchanged
|
|
1099
|
+
- [ ] `yarn build` succeeds
|
|
1100
|
+
|
|
1101
|
+
**Verify:** `bundle exec appraisal rails-8.1 rake test` and manual kanban drag in the dummy app
|
|
1102
|
+
|
|
1103
|
+
**Steps:**
|
|
1104
|
+
|
|
1105
|
+
- [ ] **Step 1: Create the shared module**
|
|
1106
|
+
|
|
1107
|
+
Create `src/js/drag/sortable.js`. Extract only what is genuinely generic — the parts of `kanban_controller.js` that reference no board concept:
|
|
1108
|
+
|
|
1109
|
+
```javascript
|
|
1110
|
+
// Shared native HTML5 drag-and-drop mechanics.
|
|
1111
|
+
//
|
|
1112
|
+
// Deliberately knows nothing about kanban columns, tables, WIP limits, or
|
|
1113
|
+
// Turbo. Consumers wire it to their own DOM contract and own the transport.
|
|
1114
|
+
//
|
|
1115
|
+
// Consumers: kanban_controller.js, positioned_controller.js
|
|
1116
|
+
|
|
1117
|
+
// Returns the 0-based insertion index for a drop at `clientY` among `items`,
|
|
1118
|
+
// by finding the first item whose vertical midpoint is below the cursor.
|
|
1119
|
+
// Extracted verbatim from kanban_controller.js#computeDropIndex.
|
|
1120
|
+
export function computeDropIndex(clientY, items) {
|
|
1121
|
+
for (let i = 0; i < items.length; i++) {
|
|
1122
|
+
const rect = items[i].getBoundingClientRect()
|
|
1123
|
+
if (clientY < rect.top + rect.height / 2) return i
|
|
1124
|
+
}
|
|
1125
|
+
return items.length
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Horizontal variant, for grid/card layouts that flow in rows.
|
|
1129
|
+
export function computeDropIndexHorizontal(clientX, items) {
|
|
1130
|
+
for (let i = 0; i < items.length; i++) {
|
|
1131
|
+
const rect = items[i].getBoundingClientRect()
|
|
1132
|
+
if (clientX < rect.left + rect.width / 2) return i
|
|
1133
|
+
}
|
|
1134
|
+
return items.length
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Applies the drag ghost + dragging class. Deferred by one frame so the
|
|
1138
|
+
// browser captures the ghost image BEFORE the opacity change — otherwise the
|
|
1139
|
+
// dragged element's ghost is rendered already-faded.
|
|
1140
|
+
export function beginDrag(event, element, { draggingClass, payload }) {
|
|
1141
|
+
event.dataTransfer.effectAllowed = "move"
|
|
1142
|
+
event.dataTransfer.setData("text/plain", payload)
|
|
1143
|
+
requestAnimationFrame(() => element.classList.add(draggingClass))
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
export function endDrag(element, { draggingClass }) {
|
|
1147
|
+
element?.classList.remove(draggingClass)
|
|
1148
|
+
}
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
- [ ] **Step 2: Consume it from kanban**
|
|
1152
|
+
|
|
1153
|
+
In `src/js/controllers/kanban_controller.js`:
|
|
1154
|
+
|
|
1155
|
+
Add at the top, with the other imports:
|
|
1156
|
+
|
|
1157
|
+
```javascript
|
|
1158
|
+
import { computeDropIndex, beginDrag, endDrag } from "../drag/sortable.js"
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
Delete the private `#computeDropIndex` method (lines 700-706) and change its call site (line 497) to the import:
|
|
1162
|
+
|
|
1163
|
+
```javascript
|
|
1164
|
+
const toIndex = computeDropIndex(event.clientY, existingCards)
|
|
1165
|
+
```
|
|
1166
|
+
|
|
1167
|
+
In `#onDragStart` (lines 433-448), replace the `dataTransfer` + `requestAnimationFrame` block with `beginDrag`, keeping the kanban-specific `#applyDropHints` call:
|
|
1168
|
+
|
|
1169
|
+
```javascript
|
|
1170
|
+
#onDragStart(event) {
|
|
1171
|
+
const card = event.target.closest("[data-kanban-record-id]")
|
|
1172
|
+
if (!card) return
|
|
1173
|
+
|
|
1174
|
+
this.draggedCard = card
|
|
1175
|
+
beginDrag(event, card, {
|
|
1176
|
+
draggingClass: "pu-kanban-dragging",
|
|
1177
|
+
payload: card.dataset.kanbanRecordId,
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
// Mark columns that would reject a drop from this card's source column.
|
|
1181
|
+
this.#applyDropHints(card.dataset.kanbanColumnKey)
|
|
1182
|
+
}
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
Use `endDrag` in the existing `#onDragEnd` cleanup, leaving the highlight and hint clearing (both board-specific) in place.
|
|
1186
|
+
|
|
1187
|
+
**Do not move** `#applyDropHints`, `#highlightColumn`, `#clearHighlights`, `#openDropInteraction`, or `#submitMove` — all are board-specific and stay in the kanban controller.
|
|
1188
|
+
|
|
1189
|
+
- [ ] **Step 3: Build and verify**
|
|
1190
|
+
|
|
1191
|
+
Run: `yarn build`
|
|
1192
|
+
Expected: completes with no errors
|
|
1193
|
+
|
|
1194
|
+
Run: `bundle exec appraisal rails-8.1 rake test`
|
|
1195
|
+
Expected: PASS
|
|
1196
|
+
|
|
1197
|
+
- [ ] **Step 4: Manually verify kanban still drags**
|
|
1198
|
+
|
|
1199
|
+
Boot the dummy app and drag a card within a column, across columns, into a WIP-limited column, and into a locked column. All four must behave exactly as before. This is a refactor — any behaviour change is a bug.
|
|
1200
|
+
|
|
1201
|
+
- [ ] **Step 5: Commit**
|
|
1202
|
+
|
|
1203
|
+
```bash
|
|
1204
|
+
git add src/js/drag/sortable.js src/js/controllers/kanban_controller.js app/assets
|
|
1205
|
+
git commit -m "refactor(js): extract shared drag mechanics from the kanban controller"
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
---
|
|
1209
|
+
|
|
1210
|
+
### Task 7: The `positioned` Stimulus controller and table grip
|
|
1211
|
+
|
|
1212
|
+
**Goal:** A hover grip in each row's first cell drags to reorder, disabled and offering a sort when the table is not in position order.
|
|
1213
|
+
|
|
1214
|
+
**Files:**
|
|
1215
|
+
- Create: `src/js/controllers/positioned_controller.js`
|
|
1216
|
+
- Create: `lib/plutonium/ui/table/components/drag_handle.rb`
|
|
1217
|
+
- Modify: `src/js/controllers/register_controllers.js:39,82`
|
|
1218
|
+
- Modify: `lib/plutonium/ui/table/resource.rb:~152` (wrap the first column's block)
|
|
1219
|
+
|
|
1220
|
+
**Acceptance Criteria:**
|
|
1221
|
+
- [ ] Only the grip carries `draggable="true"` — never the `<tr>`
|
|
1222
|
+
- [ ] Grip is hidden until row hover, and visible on keyboard focus
|
|
1223
|
+
- [ ] Grip is a `<button>`
|
|
1224
|
+
- [ ] When the effective sort is not the position attribute, the grip is disabled and applies the position sort on click
|
|
1225
|
+
- [ ] Dropping posts `{prev_id, next_id, to_index}` and applies the turbo-stream response
|
|
1226
|
+
- [ ] No grip renders when the definition has no `position_on` or is Mode C
|
|
1227
|
+
- [ ] **Nested tables work too**: the reposition URL is the *nested* member route, and neighbours resolve within the parent-scoped `current_authorized_scope`
|
|
1228
|
+
|
|
1229
|
+
**Verify:** `yarn build` succeeds; manual drag in the dummy app on `/tasks`
|
|
1230
|
+
|
|
1231
|
+
**Steps:**
|
|
1232
|
+
|
|
1233
|
+
- [ ] **Step 1: Write the grip component**
|
|
1234
|
+
|
|
1235
|
+
Create `lib/plutonium/ui/table/components/drag_handle.rb`:
|
|
1236
|
+
|
|
1237
|
+
```ruby
|
|
1238
|
+
# frozen_string_literal: true
|
|
1239
|
+
|
|
1240
|
+
module Plutonium
|
|
1241
|
+
module UI
|
|
1242
|
+
module Table
|
|
1243
|
+
module Components
|
|
1244
|
+
# The drag affordance for a positioned row.
|
|
1245
|
+
#
|
|
1246
|
+
# Lives INSIDE the first cell, not in a column of its own — a permanent
|
|
1247
|
+
# grip column costs horizontal space on every row for a feature used
|
|
1248
|
+
# occasionally.
|
|
1249
|
+
#
|
|
1250
|
+
# Only this element is draggable, never the <tr>. draggable="true"
|
|
1251
|
+
# disables text selection within the element in every major browser, so
|
|
1252
|
+
# a draggable row would silently remove the ability to select and copy a
|
|
1253
|
+
# cell value — and would fight row_click_controller.js, which already
|
|
1254
|
+
# makes the whole row the Show affordance.
|
|
1255
|
+
#
|
|
1256
|
+
# When `sort_url` is present the table is NOT in position order, so the
|
|
1257
|
+
# grip renders disabled and doubles as the control that restores it.
|
|
1258
|
+
class DragHandle < Phlexi::Table::HTML
|
|
1259
|
+
def initialize(record_id:, sort_url: nil)
|
|
1260
|
+
@record_id = record_id
|
|
1261
|
+
@sort_url = sort_url
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
def view_template
|
|
1265
|
+
@sort_url ? render_sort_affordance : render_grip
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
private
|
|
1269
|
+
|
|
1270
|
+
def render_grip
|
|
1271
|
+
# The handle carries NO record id — the row it lives in owns that
|
|
1272
|
+
# (data-positioned-row-id). One source of truth; the controller
|
|
1273
|
+
# always reads the id from the row.
|
|
1274
|
+
button(
|
|
1275
|
+
type: :button,
|
|
1276
|
+
draggable: "true",
|
|
1277
|
+
class: themed(:drag_handle),
|
|
1278
|
+
aria_label: "Drag to reorder. Use arrow keys to move up or down.",
|
|
1279
|
+
data: {
|
|
1280
|
+
positioned_target: "handle",
|
|
1281
|
+
action: "keydown->positioned#onHandleKeydown"
|
|
1282
|
+
}
|
|
1283
|
+
) { render_grip_icon }
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
# Disabled state: the table is sorted by another column, so the visible
|
|
1287
|
+
# neighbours are not position-adjacent and a drop would be meaningless.
|
|
1288
|
+
# Clicking restores position order rather than explaining where to find
|
|
1289
|
+
# the control.
|
|
1290
|
+
def render_sort_affordance
|
|
1291
|
+
a(
|
|
1292
|
+
href: @sort_url,
|
|
1293
|
+
class: themed(:drag_handle_disabled),
|
|
1294
|
+
title: "Sort by position to reorder",
|
|
1295
|
+
data: {turbo_frame: "_top"}
|
|
1296
|
+
) { render_grip_icon }
|
|
1297
|
+
end
|
|
1298
|
+
|
|
1299
|
+
def render_grip_icon
|
|
1300
|
+
render Phlex::TablerIcons::GripVertical.new(class: "size-4")
|
|
1301
|
+
end
|
|
1302
|
+
end
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
1305
|
+
end
|
|
1306
|
+
end
|
|
1307
|
+
```
|
|
1308
|
+
|
|
1309
|
+
Add `drag_handle` and `drag_handle_disabled` to `lib/plutonium/ui/table/theme.rb`. The grip must be invisible until hover but visible on focus:
|
|
1310
|
+
|
|
1311
|
+
```ruby
|
|
1312
|
+
drag_handle: "opacity-0 group-hover/row:opacity-100 focus:opacity-100 " \
|
|
1313
|
+
"cursor-grab active:cursor-grabbing text-[var(--pu-text-subtle)] " \
|
|
1314
|
+
"hover:text-[var(--pu-text)] transition-opacity",
|
|
1315
|
+
drag_handle_disabled: "opacity-0 group-hover/row:opacity-40 focus:opacity-100 " \
|
|
1316
|
+
"cursor-pointer text-[var(--pu-text-subtle)] transition-opacity",
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
This requires `group/row` on the `<tr>` — check `lib/plutonium/ui/table/base.rb` for where row attributes are set and add it there if absent.
|
|
1320
|
+
|
|
1321
|
+
- [ ] **Step 2: Render the grip in the first cell**
|
|
1322
|
+
|
|
1323
|
+
In `lib/plutonium/ui/table/resource.rb`, inside the `@resource_fields.each` loop (around line 152), wrap the first field's `tag_block` so the grip precedes the cell content. Add before the loop:
|
|
1324
|
+
|
|
1325
|
+
```ruby
|
|
1326
|
+
position_config = resource_definition.defined_position_config
|
|
1327
|
+
positioned = position_config && !position_config.disabled?
|
|
1328
|
+
first_field = @resource_fields.first
|
|
1329
|
+
# nil when the table IS in position order (grip is live); a URL when
|
|
1330
|
+
# it is not (grip is disabled and links back to position order).
|
|
1331
|
+
position_sort_url = if positioned && !ordered_by_position?(position_config)
|
|
1332
|
+
current_query_object.sort_params_for(position_config.attribute)[:url]
|
|
1333
|
+
end
|
|
1334
|
+
```
|
|
1335
|
+
|
|
1336
|
+
Then, where `tag_block` is assigned, wrap it for the first field only:
|
|
1337
|
+
|
|
1338
|
+
```ruby
|
|
1339
|
+
if positioned && name == first_field
|
|
1340
|
+
inner_block = tag_block
|
|
1341
|
+
tag_block = ->(wrapped_object, key) {
|
|
1342
|
+
div(class: "flex items-center gap-2") do
|
|
1343
|
+
render DragHandle.new(
|
|
1344
|
+
record_id: wrapped_object.unwrapped.id,
|
|
1345
|
+
sort_url: position_sort_url
|
|
1346
|
+
)
|
|
1347
|
+
render inner_block.call(wrapped_object, key)
|
|
1348
|
+
end
|
|
1349
|
+
}
|
|
1350
|
+
end
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
Add a private `ordered_by_position?(config)` that compares the query object's active sort field to `config.attribute`. Read `lib/plutonium/resource/query_object.rb` for the exact accessor — do not guess it.
|
|
1354
|
+
|
|
1355
|
+
- [ ] **Step 3: Write the Stimulus controller**
|
|
1356
|
+
|
|
1357
|
+
Create `src/js/controllers/positioned_controller.js`:
|
|
1358
|
+
|
|
1359
|
+
```javascript
|
|
1360
|
+
import { Controller } from "@hotwired/stimulus"
|
|
1361
|
+
import { computeDropIndex, beginDrag, endDrag } from "../drag/sortable.js"
|
|
1362
|
+
|
|
1363
|
+
// Connects to data-controller="positioned"
|
|
1364
|
+
//
|
|
1365
|
+
// Drag-to-reorder for tables, nested tables, and grids backed by a `position_on`
|
|
1366
|
+
// definition. Only the grip is draggable (see DragHandle for why).
|
|
1367
|
+
//
|
|
1368
|
+
// The client moves the row optimistically. A clean drop answers 204 and nothing
|
|
1369
|
+
// more happens; when the server needs to reconcile it streams the collection
|
|
1370
|
+
// back, which replaces whatever the client did.
|
|
1371
|
+
export default class extends Controller {
|
|
1372
|
+
static values = { url: String }
|
|
1373
|
+
static targets = ["handle", "row"]
|
|
1374
|
+
|
|
1375
|
+
connect() {
|
|
1376
|
+
this.onDragStart = this.#onDragStart.bind(this)
|
|
1377
|
+
this.onDragOver = this.#onDragOver.bind(this)
|
|
1378
|
+
this.onDrop = this.#onDrop.bind(this)
|
|
1379
|
+
this.onDragEnd = this.#onDragEnd.bind(this)
|
|
1380
|
+
|
|
1381
|
+
this.element.addEventListener("dragstart", this.onDragStart)
|
|
1382
|
+
this.element.addEventListener("dragover", this.onDragOver)
|
|
1383
|
+
this.element.addEventListener("drop", this.onDrop)
|
|
1384
|
+
this.element.addEventListener("dragend", this.onDragEnd)
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
disconnect() {
|
|
1388
|
+
this.element.removeEventListener("dragstart", this.onDragStart)
|
|
1389
|
+
this.element.removeEventListener("dragover", this.onDragOver)
|
|
1390
|
+
this.element.removeEventListener("drop", this.onDrop)
|
|
1391
|
+
this.element.removeEventListener("dragend", this.onDragEnd)
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
#onDragStart(event) {
|
|
1395
|
+
const handle = event.target.closest("[data-positioned-target='handle']")
|
|
1396
|
+
if (!handle) return
|
|
1397
|
+
|
|
1398
|
+
this.draggedRow = handle.closest("[data-positioned-target='row']")
|
|
1399
|
+
if (!this.draggedRow) return
|
|
1400
|
+
|
|
1401
|
+
beginDrag(event, this.draggedRow, {
|
|
1402
|
+
draggingClass: "pu-positioned-dragging",
|
|
1403
|
+
payload: this.draggedRow.dataset.positionedRowId,
|
|
1404
|
+
})
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
#onDragOver(event) {
|
|
1408
|
+
if (!this.draggedRow) return
|
|
1409
|
+
event.preventDefault()
|
|
1410
|
+
event.dataTransfer.dropEffect = "move"
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
#onDrop(event) {
|
|
1414
|
+
event.preventDefault()
|
|
1415
|
+
if (!this.draggedRow) return
|
|
1416
|
+
|
|
1417
|
+
const others = this.rowTargets.filter(r => r !== this.draggedRow)
|
|
1418
|
+
const toIndex = computeDropIndex(event.clientY, others)
|
|
1419
|
+
|
|
1420
|
+
// Optimistic move: insert before the row now at toIndex, or append.
|
|
1421
|
+
const anchor = others[toIndex]
|
|
1422
|
+
if (anchor) {
|
|
1423
|
+
anchor.parentNode.insertBefore(this.draggedRow, anchor)
|
|
1424
|
+
} else if (others.length) {
|
|
1425
|
+
const last = others[others.length - 1]
|
|
1426
|
+
last.parentNode.insertBefore(this.draggedRow, last.nextSibling)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
this.#submit(this.draggedRow, {
|
|
1430
|
+
prevId: others[toIndex - 1]?.dataset.positionedRowId ?? "",
|
|
1431
|
+
nextId: others[toIndex]?.dataset.positionedRowId ?? "",
|
|
1432
|
+
toIndex,
|
|
1433
|
+
})
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
#onDragEnd() {
|
|
1437
|
+
endDrag(this.draggedRow, { draggingClass: "pu-positioned-dragging" })
|
|
1438
|
+
this.draggedRow = null
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Keyboard reorder: native HTML5 DnD is mouse-only, so without this the
|
|
1442
|
+
// feature is unusable by keyboard. Moves one slot per keypress, reusing the
|
|
1443
|
+
// same endpoint and neighbour computation as a drag.
|
|
1444
|
+
onHandleKeydown(event) {
|
|
1445
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return
|
|
1446
|
+
event.preventDefault()
|
|
1447
|
+
|
|
1448
|
+
const row = event.target.closest("[data-positioned-target='row']")
|
|
1449
|
+
if (!row) return
|
|
1450
|
+
|
|
1451
|
+
const rows = this.rowTargets
|
|
1452
|
+
const from = rows.indexOf(row)
|
|
1453
|
+
const to = event.key === "ArrowUp" ? from - 1 : from + 1
|
|
1454
|
+
if (to < 0 || to >= rows.length) return
|
|
1455
|
+
|
|
1456
|
+
const anchor = event.key === "ArrowUp" ? rows[to] : rows[to].nextSibling
|
|
1457
|
+
row.parentNode.insertBefore(row, anchor)
|
|
1458
|
+
event.target.focus()
|
|
1459
|
+
|
|
1460
|
+
const others = this.rowTargets.filter(r => r !== row)
|
|
1461
|
+
this.#submit(row, {
|
|
1462
|
+
prevId: others[to - 1]?.dataset.positionedRowId ?? "",
|
|
1463
|
+
nextId: others[to]?.dataset.positionedRowId ?? "",
|
|
1464
|
+
toIndex: to,
|
|
1465
|
+
})
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async #submit(row, { prevId, nextId, toIndex }) {
|
|
1469
|
+
const url = this.urlValue.replace("__ID__", row.dataset.positionedRowId)
|
|
1470
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
1471
|
+
|
|
1472
|
+
const response = await fetch(url, {
|
|
1473
|
+
method: "POST",
|
|
1474
|
+
headers: {
|
|
1475
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
1476
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1477
|
+
"X-CSRF-Token": csrfToken,
|
|
1478
|
+
},
|
|
1479
|
+
body: new URLSearchParams({ prev_id: prevId, next_id: nextId, to_index: toIndex }),
|
|
1480
|
+
credentials: "same-origin",
|
|
1481
|
+
})
|
|
1482
|
+
|
|
1483
|
+
// 204 means the optimistic DOM is already correct — nothing to apply.
|
|
1484
|
+
if (response.status === 204) return
|
|
1485
|
+
|
|
1486
|
+
const html = await response.text()
|
|
1487
|
+
if (html) window.Turbo.renderStreamMessage(html)
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
- [ ] **Step 4: Register the controller**
|
|
1493
|
+
|
|
1494
|
+
In `src/js/controllers/register_controllers.js`, add the import beside the others (near line 39):
|
|
1495
|
+
|
|
1496
|
+
```javascript
|
|
1497
|
+
import PositionedController from "./positioned_controller.js"
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
and the registration (near line 82):
|
|
1501
|
+
|
|
1502
|
+
```javascript
|
|
1503
|
+
application.register("positioned", PositionedController)
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
- [ ] **Step 5: Wire the controller onto the table**
|
|
1507
|
+
|
|
1508
|
+
The table wrapper needs `data-controller="positioned"` and `data-positioned-url-value` (the member reposition path with `__ID__`); each row needs `data-positioned-target="row"` and `data-positioned-row-id`. Add these in `lib/plutonium/ui/table/resource.rb` where the table wrapper is built — mirror how the kanban board supplies `moveUrlTemplateValue` (`Kanban::Resource#kanban_move_url_template`).
|
|
1509
|
+
|
|
1510
|
+
**The server contract Task 5 actually shipped** — this supersedes the ids sketched earlier in this plan, which were guesses made before the endpoint existed:
|
|
1511
|
+
|
|
1512
|
+
| Thing | Value | Source |
|
|
1513
|
+
|---|---|---|
|
|
1514
|
+
| Turbo-stream target | `#pu-collection-<model_name.plural>` (e.g. `#pu-collection-tasks`) | `Plutonium::UI::Page::Index.collection_dom_id` (`ui/page/index.rb:33`) |
|
|
1515
|
+
| Toast region | `#pu-flash` | `Plutonium::FLASH_REGION` (`lib/plutonium.rb:46`) — lives in the layout, deliberately OUTSIDE the collection wrapper so a stream replacing the collection cannot destroy it |
|
|
1516
|
+
|
|
1517
|
+
The wrapper `div` already exists (`ui/page/index.rb:81`) and wraps the rendered table **or** grid — so Task 8 needs no separate target.
|
|
1518
|
+
|
|
1519
|
+
**The client MUST append the collection's current query string to the POST URL** (i.e. `window.location.search`). This is load-bearing, not cosmetic: the endpoint re-renders through the index's own pipeline via `setup_index_action!`, so search / filters / scope / sort / page all arrive in `params`. Without it, a drop on page 3 of a filtered list reconciles as page 1 of the unfiltered list.
|
|
1520
|
+
|
|
1521
|
+
- [ ] **Step 6: Verify the nested-table URL**
|
|
1522
|
+
|
|
1523
|
+
Nested association tables render through this same `Table::Resource` component, so the grip comes for free — but the reposition URL must be the **nested** member route (`/posts/1/comments/5/reposition`), not the top-level one. If Step 5 builds the URL from `resource_url_for(resource_class)`, that resolves correctly in a nested context already; verify it rather than assume.
|
|
1524
|
+
|
|
1525
|
+
`KanbanActions#render_kanban_column_html` (`kanban_actions.rb:616`) documents why the URL is derived from `resource_url_for` and not `request.path` — the same reasoning applies here, because the reposition POST's own `request.path` is the member route, not the collection path.
|
|
1526
|
+
|
|
1527
|
+
Confirm in the dummy app on a parent's show page that a nested positioned collection drags and persists, and that its POST goes to the nested path.
|
|
1528
|
+
|
|
1529
|
+
- [ ] **Step 7: Build and verify manually**
|
|
1530
|
+
|
|
1531
|
+
Run: `yarn build`
|
|
1532
|
+
Expected: no errors
|
|
1533
|
+
|
|
1534
|
+
Boot the dummy app, visit the Tasks index, and confirm: grip appears on hover; dragging reorders and persists across reload; sorting by another column greys the grip; clicking the greyed grip restores position order; tabbing to a grip and pressing ArrowUp/ArrowDown moves the row.
|
|
1535
|
+
|
|
1536
|
+
- [ ] **Step 8: Commit**
|
|
1537
|
+
|
|
1538
|
+
```bash
|
|
1539
|
+
git add src/js/controllers/positioned_controller.js src/js/controllers/register_controllers.js \
|
|
1540
|
+
lib/plutonium/ui/table/components/drag_handle.rb lib/plutonium/ui/table/resource.rb \
|
|
1541
|
+
lib/plutonium/ui/table/theme.rb lib/plutonium/ui/table/base.rb app/assets
|
|
1542
|
+
git commit -m "feat(ui): drag-to-reorder for positioned index tables"
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
---
|
|
1546
|
+
|
|
1547
|
+
### Task 8: Grid/card surface
|
|
1548
|
+
|
|
1549
|
+
**Goal:** The same grip and controller on grid/card index views.
|
|
1550
|
+
|
|
1551
|
+
**Files:**
|
|
1552
|
+
- Modify: `lib/plutonium/ui/grid/card.rb`
|
|
1553
|
+
- Modify: `lib/plutonium/ui/grid/resource.rb`
|
|
1554
|
+
|
|
1555
|
+
**Acceptance Criteria:**
|
|
1556
|
+
- [ ] Cards render the grip when the definition is positioned
|
|
1557
|
+
- [ ] Grid uses `computeDropIndexHorizontal` where cards flow in rows
|
|
1558
|
+
- [ ] The grid wrapper carries the same `positioned` controller wiring as the table
|
|
1559
|
+
|
|
1560
|
+
**Verify:** `yarn build`; manual drag on a grid index in the dummy app
|
|
1561
|
+
|
|
1562
|
+
**Steps:**
|
|
1563
|
+
|
|
1564
|
+
- [ ] **Step 1: Render the grip on the card**
|
|
1565
|
+
|
|
1566
|
+
In `lib/plutonium/ui/grid/card.rb`, render `Plutonium::UI::Table::Components::DragHandle` in the card header when `resource_definition.defined_position_config` is present and not disabled. Reuse the component rather than writing a second one — the affordance and its rationale are identical.
|
|
1567
|
+
|
|
1568
|
+
- [ ] **Step 2: Wire the grid wrapper**
|
|
1569
|
+
|
|
1570
|
+
In `lib/plutonium/ui/grid/resource.rb`, add the same `data-controller="positioned"`, `data-positioned-url-value`, and per-card `data-positioned-target="row"` / `data-positioned-row-id` as the table (Task 7 Step 5).
|
|
1571
|
+
|
|
1572
|
+
No separate turbo-stream target is needed: `#pu-collection-<plural>` (`ui/page/index.rb:81`) already wraps the rendered grid as well as the table, and `#pu-flash` is layout-level. The same query-string requirement applies.
|
|
1573
|
+
|
|
1574
|
+
- [ ] **Step 3: Select the horizontal index computation**
|
|
1575
|
+
|
|
1576
|
+
Add a `data-positioned-axis-value` of `"horizontal"` on the grid wrapper, and in `positioned_controller.js` branch on it:
|
|
1577
|
+
|
|
1578
|
+
```javascript
|
|
1579
|
+
static values = { url: String, axis: { type: String, default: "vertical" } }
|
|
1580
|
+
|
|
1581
|
+
#dropIndex(event, items) {
|
|
1582
|
+
return this.axisValue === "horizontal"
|
|
1583
|
+
? computeDropIndexHorizontal(event.clientX, items)
|
|
1584
|
+
: computeDropIndex(event.clientY, items)
|
|
1585
|
+
}
|
|
1586
|
+
```
|
|
1587
|
+
|
|
1588
|
+
Replace both `computeDropIndex(event.clientY, others)` call sites with `this.#dropIndex(event, others)`.
|
|
1589
|
+
|
|
1590
|
+
- [ ] **Step 4: Build and verify**
|
|
1591
|
+
|
|
1592
|
+
Run: `yarn build`
|
|
1593
|
+
Expected: no errors
|
|
1594
|
+
|
|
1595
|
+
Manually drag a card on a grid index and confirm it reorders and persists.
|
|
1596
|
+
|
|
1597
|
+
- [ ] **Step 5: Commit**
|
|
1598
|
+
|
|
1599
|
+
```bash
|
|
1600
|
+
git add lib/plutonium/ui/grid/card.rb lib/plutonium/ui/grid/resource.rb \
|
|
1601
|
+
src/js/controllers/positioned_controller.js app/assets
|
|
1602
|
+
git commit -m "feat(ui): drag-to-reorder for positioned grid views"
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
---
|
|
1606
|
+
|
|
1607
|
+
### Task 9: System test
|
|
1608
|
+
|
|
1609
|
+
**Goal:** An end-to-end test that a real drag and a real keyboard reorder both persist.
|
|
1610
|
+
|
|
1611
|
+
**Files:**
|
|
1612
|
+
- Create: `test/system/positioned_drag_test.rb`
|
|
1613
|
+
- Modify: `test/dummy/app/definitions/task_definition.rb` (add `position_on`)
|
|
1614
|
+
|
|
1615
|
+
**Acceptance Criteria:**
|
|
1616
|
+
- [ ] A drag on the Tasks index reorders and survives a reload
|
|
1617
|
+
- [ ] ArrowDown on a focused grip reorders and survives a reload
|
|
1618
|
+
- [ ] Sorting by another column disables the grip
|
|
1619
|
+
|
|
1620
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/system/positioned_drag_test.rb` → all pass
|
|
1621
|
+
|
|
1622
|
+
**Steps:**
|
|
1623
|
+
|
|
1624
|
+
- [ ] **Step 1: Declare `position_on` on the dummy Task definition**
|
|
1625
|
+
|
|
1626
|
+
`Task` already has `positioned_on :position, scope: :status` (`test/dummy/app/models/task.rb:10`). Add to `test/dummy/app/definitions/task_definition.rb`:
|
|
1627
|
+
|
|
1628
|
+
```ruby
|
|
1629
|
+
position_on
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
**Important:** `test/dummy` is git-cleaned by the generator tests. Stage this change before running the suite, or it will be reverted mid-run.
|
|
1633
|
+
|
|
1634
|
+
- [ ] **Step 2: Write the system test**
|
|
1635
|
+
|
|
1636
|
+
Create `test/system/positioned_drag_test.rb`, following the conventions of the existing tests in `test/system/` (read one first for the driver setup and sign-in helper).
|
|
1637
|
+
|
|
1638
|
+
```ruby
|
|
1639
|
+
# frozen_string_literal: true
|
|
1640
|
+
|
|
1641
|
+
require "application_system_test_case"
|
|
1642
|
+
|
|
1643
|
+
class PositionedDragTest < ApplicationSystemTestCase
|
|
1644
|
+
def test_dragging_a_row_reorders_it_and_persists
|
|
1645
|
+
visit_tasks_index
|
|
1646
|
+
|
|
1647
|
+
first_row_id = page.all("[data-positioned-target='row']").first["data-positioned-row-id"]
|
|
1648
|
+
drag_row(first_row_id, to_index: 2)
|
|
1649
|
+
|
|
1650
|
+
visit current_path
|
|
1651
|
+
refute_equal first_row_id,
|
|
1652
|
+
page.all("[data-positioned-target='row']").first["data-positioned-row-id"]
|
|
1653
|
+
end
|
|
1654
|
+
|
|
1655
|
+
def test_keyboard_reorder_persists
|
|
1656
|
+
visit_tasks_index
|
|
1657
|
+
|
|
1658
|
+
first_row_id = page.all("[data-positioned-target='row']").first["data-positioned-row-id"]
|
|
1659
|
+
find("[data-positioned-row-id='#{first_row_id}'] [data-positioned-target='handle']")
|
|
1660
|
+
.send_keys(:arrow_down)
|
|
1661
|
+
|
|
1662
|
+
visit current_path
|
|
1663
|
+
refute_equal first_row_id,
|
|
1664
|
+
page.all("[data-positioned-target='row']").first["data-positioned-row-id"]
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1667
|
+
def test_sorting_by_another_column_disables_the_grip
|
|
1668
|
+
visit_tasks_index
|
|
1669
|
+
click_on "Name"
|
|
1670
|
+
|
|
1671
|
+
assert_selector "[title='Sort by position to reorder']"
|
|
1672
|
+
assert_no_selector "[data-positioned-target='handle']"
|
|
1673
|
+
end
|
|
1674
|
+
end
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
- [ ] **Step 3: Run**
|
|
1678
|
+
|
|
1679
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/system/positioned_drag_test.rb`
|
|
1680
|
+
Expected: PASS
|
|
1681
|
+
|
|
1682
|
+
- [ ] **Step 4: Run the full suite across Rails versions**
|
|
1683
|
+
|
|
1684
|
+
Run: `bundle exec appraisal rake test`
|
|
1685
|
+
Expected: PASS on rails-7, rails-8.0, rails-8.1
|
|
1686
|
+
|
|
1687
|
+
- [ ] **Step 5: Commit**
|
|
1688
|
+
|
|
1689
|
+
```bash
|
|
1690
|
+
git add test/system/positioned_drag_test.rb test/dummy/app/definitions/task_definition.rb
|
|
1691
|
+
git commit -m "test(positioning): system tests for drag and keyboard reorder"
|
|
1692
|
+
```
|
|
1693
|
+
|
|
1694
|
+
---
|
|
1695
|
+
|
|
1696
|
+
### Task 10: Documentation and skills
|
|
1697
|
+
|
|
1698
|
+
**Goal:** The feature is discoverable, and the Mode B escape hatch is documented with a worked third-party example.
|
|
1699
|
+
|
|
1700
|
+
**Files:**
|
|
1701
|
+
- Create: `docs/reference/positioning.md`
|
|
1702
|
+
- Modify: `docs/reference/resource/actions.md` (hidden actions)
|
|
1703
|
+
- Modify: `.claude/skills/plutonium-resource/SKILL.md`
|
|
1704
|
+
- Modify: `.claude/skills/plutonium-ui/SKILL.md`
|
|
1705
|
+
- Modify: `docs/.vitepress/config.*` (sidebar entry)
|
|
1706
|
+
|
|
1707
|
+
**Acceptance Criteria:**
|
|
1708
|
+
- [ ] The model/definition split is explained: `positioned_on` vs `position_on`
|
|
1709
|
+
- [ ] All three modes documented, with a worked `acts_as_list` example
|
|
1710
|
+
- [ ] The nested-scope contract from spec §2.1 is stated
|
|
1711
|
+
- [ ] The implicit `default_sort` override is called out prominently
|
|
1712
|
+
- [ ] The deliberate table-vs-kanban affordance difference is explained
|
|
1713
|
+
- [ ] Hidden actions documented, including that they are NOT an authorization boundary
|
|
1714
|
+
- [ ] **The `include Plutonium::Positioning` → `::Model` breaking change is called out** with a one-line upgrade note, and the reason (constant leakage into user models — spec §2.0)
|
|
1715
|
+
- [ ] `yarn docs:build` succeeds with no broken links
|
|
1716
|
+
|
|
1717
|
+
**Verify:** `yarn docs:build` → completes, no broken links
|
|
1718
|
+
|
|
1719
|
+
**Steps:**
|
|
1720
|
+
|
|
1721
|
+
- [ ] **Step 1: Write `docs/reference/positioning.md`**
|
|
1722
|
+
|
|
1723
|
+
Cover, in order: the model layer (`include Plutonium::Positioning::Model`, `positioned_on`, the `t.position` migration helper); the definition layer (`position_on` and its four forms); what `position_on` expands to, with the `default_sort` override called out in a warning callout; when dragging is enabled and how the disabled grip restores position order; the nested-scope contract; and a worked Mode B example:
|
|
1724
|
+
|
|
1725
|
+
```ruby
|
|
1726
|
+
# Using acts_as_list instead of Plutonium::Positioning
|
|
1727
|
+
class TaskDefinition < Plutonium::Resource::Definition
|
|
1728
|
+
position_on :position do |move|
|
|
1729
|
+
move.record.insert_at(move.index + 1)
|
|
1730
|
+
end
|
|
1731
|
+
end
|
|
1732
|
+
```
|
|
1733
|
+
|
|
1734
|
+
Note that Mode B always reconciles, and why (`acts_as_list` renumbers the whole group).
|
|
1735
|
+
|
|
1736
|
+
- [ ] **Step 2: Document hidden actions**
|
|
1737
|
+
|
|
1738
|
+
In `docs/reference/resource/actions.md`, add a section on `hidden: true`: what it gives you (route, policy predicate, form/params machinery), what it withholds (every render surface), and the warning that it is a display gate, not an authorization boundary — authorization belongs in the policy.
|
|
1739
|
+
|
|
1740
|
+
- [ ] **Step 3: Update the skills**
|
|
1741
|
+
|
|
1742
|
+
`plutonium-resource`: `position_on` in the definition DSL reference, alongside `sort` and `default_sort`.
|
|
1743
|
+
`plutonium-ui`: the drag affordance, the table-vs-kanban difference and why (text selection + `row_click`).
|
|
1744
|
+
|
|
1745
|
+
Both skills require a gem release to take effect — note that in the commit message, not in the docs.
|
|
1746
|
+
|
|
1747
|
+
- [ ] **Step 4: Add the sidebar entry**
|
|
1748
|
+
|
|
1749
|
+
Add `positioning.md` to the reference section of the VitePress sidebar.
|
|
1750
|
+
|
|
1751
|
+
- [ ] **Step 5: Build the docs**
|
|
1752
|
+
|
|
1753
|
+
Run: `yarn docs:build`
|
|
1754
|
+
Expected: completes, no broken links
|
|
1755
|
+
|
|
1756
|
+
- [ ] **Step 6: Commit**
|
|
1757
|
+
|
|
1758
|
+
```bash
|
|
1759
|
+
git add docs .claude/skills
|
|
1760
|
+
git commit -m "docs(positioning): document position_on, hidden actions, and the block escape hatch"
|
|
1761
|
+
```
|
|
1762
|
+
|
|
1763
|
+
---
|
|
1764
|
+
|
|
1765
|
+
## Verification Summary
|
|
1766
|
+
|
|
1767
|
+
| Task | Command |
|
|
1768
|
+
|---|---|
|
|
1769
|
+
| 1 | `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb` (unmodified) |
|
|
1770
|
+
| 2 | `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb` |
|
|
1771
|
+
| 3 | `rg -n "kanban_drop\b" lib app test` → no output; then `bundle exec appraisal rails-8.1 rake test` |
|
|
1772
|
+
| 4 | `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb` |
|
|
1773
|
+
| 5 | `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/controllers/position_actions_test.rb` |
|
|
1774
|
+
| 6 | `yarn build` + `bundle exec appraisal rails-8.1 rake test` + manual kanban drag |
|
|
1775
|
+
| 7 | `yarn build` + manual drag on `/tasks` |
|
|
1776
|
+
| 8 | `yarn build` + manual drag on a grid index |
|
|
1777
|
+
| 9 | `bundle exec appraisal rake test` (all Rails versions) |
|
|
1778
|
+
| 10 | `yarn docs:build` |
|
|
1779
|
+
|
|
1780
|
+
## Known Open Points
|
|
1781
|
+
|
|
1782
|
+
These are places where the plan deliberately says "locate the integration point" rather than inventing an API, because the exact seam was not verified during planning. The implementer must read the named file first:
|
|
1783
|
+
|
|
1784
|
+
1. **Task 5 Step 5** — `position_collection_frame_id` and `render_position_collection_html` must reuse `CrudActions`' existing collection-rendering path (`lib/plutonium/resource/controllers/crud_actions.rb`), modelled on `KanbanActions#render_kanban_column_html` (`kanban_actions.rb:568`).
|
|
1785
|
+
2. **Task 7 Step 2** — the active-sort accessor on `lib/plutonium/resource/query_object.rb`, for `ordered_by_position?`.
|
|
1786
|
+
3. **Task 7 Step 5** — where the table wrapper's attributes are built in `lib/plutonium/ui/table/resource.rb`.
|
|
1787
|
+
4. **Task 7 Step 1** — whether `<tr>` already carries a `group/row` class in `lib/plutonium/ui/table/base.rb`.
|