plutonium 0.62.2 → 0.63.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/CHANGELOG.md +67 -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 +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- 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 +29 -11
- 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 +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { beginDrag, computeDropIndex, computeDropIndexHorizontal, endDrag, hideInsertionMarker, showInsertionMarker } from "../drag/sortable"
|
|
3
|
+
|
|
4
|
+
// Applied to the row for the duration of the drag. A plain Tailwind utility
|
|
5
|
+
// rather than a bespoke class, so the whole affordance needs no stylesheet of
|
|
6
|
+
// its own — the same reason the grip itself is themed from Table::Theme.
|
|
7
|
+
const DRAGGING_CLASS = "opacity-30"
|
|
8
|
+
|
|
9
|
+
// Connects to data-controller="positioned"
|
|
10
|
+
//
|
|
11
|
+
// Drag-to-reorder for an index collection whose resource declares
|
|
12
|
+
// `position_on`. Native HTML5 drag-and-drop, sharing its mechanics with the
|
|
13
|
+
// kanban board via ../drag/sortable — no npm dependency.
|
|
14
|
+
//
|
|
15
|
+
// Serves BOTH index surfaces — the table and the card grid. They differ in
|
|
16
|
+
// exactly one respect (which axis a drop position is read off), so the grid is
|
|
17
|
+
// a second consumer of this controller rather than a parallel one; "row" below
|
|
18
|
+
// means a <tr> on a table and an <article> card on a grid.
|
|
19
|
+
//
|
|
20
|
+
// ## DOM contract
|
|
21
|
+
//
|
|
22
|
+
// Collection wrapper (this element — the div around <table>, or the grid div):
|
|
23
|
+
// data-controller="positioned"
|
|
24
|
+
// data-positioned-url-template-value="/things/__ID__/reposition"
|
|
25
|
+
// — the collection path with an __ID__ placeholder, substituted with the
|
|
26
|
+
// dragged row's record id at drop time.
|
|
27
|
+
// data-positioned-axis-value="horizontal"
|
|
28
|
+
// — grid only. See #dropIndex.
|
|
29
|
+
//
|
|
30
|
+
// Row (<tr> or a grid <article>):
|
|
31
|
+
// data-positioned-row-id="<id>"
|
|
32
|
+
// — the SINGLE source of truth for which record a drag is about. The grip
|
|
33
|
+
// deliberately carries no id of its own: two places to read it from is
|
|
34
|
+
// two places for them to disagree.
|
|
35
|
+
//
|
|
36
|
+
// Grip (a <button> inside the row):
|
|
37
|
+
// data-positioned-grip
|
|
38
|
+
//
|
|
39
|
+
// ## Only the grip is draggable — never the <tr>
|
|
40
|
+
//
|
|
41
|
+
// Two concrete reasons, both of which cost real functionality if ignored:
|
|
42
|
+
//
|
|
43
|
+
// 1. `draggable="true"` disables text selection inside the element in every
|
|
44
|
+
// major browser. On a data table that silently removes the ability to
|
|
45
|
+
// select and copy a cell value.
|
|
46
|
+
// 2. row_click_controller makes the whole row the Show affordance. A
|
|
47
|
+
// draggable row fights it: a drag that starts and ends in place still
|
|
48
|
+
// fires a click, and the user is navigated away instead of being left
|
|
49
|
+
// where they were.
|
|
50
|
+
//
|
|
51
|
+
// The kanban board keeps whole-card dragging because neither concern applies
|
|
52
|
+
// to a card. A GRID card, however, still gets a grip: unlike a kanban card it
|
|
53
|
+
// carries a row-click show affordance, so reason 2 applies to it just as it
|
|
54
|
+
// does to a table row.
|
|
55
|
+
//
|
|
56
|
+
// ## The wrapper only appears while the table IS in position order
|
|
57
|
+
//
|
|
58
|
+
// Under any other sort the visible neighbours sit in arbitrary position order,
|
|
59
|
+
// so "drop me between these two rows" describes nothing the server can honour
|
|
60
|
+
// — and it rejects such a drop outright (422, no write). The server-rendered
|
|
61
|
+
// table therefore omits this controller entirely under a foreign sort and
|
|
62
|
+
// renders the grip as a disabled link that applies the position sort instead.
|
|
63
|
+
// So this controller never has to reason about sorting: if it is connected,
|
|
64
|
+
// dragging is permitted.
|
|
65
|
+
//
|
|
66
|
+
// ## Move flow
|
|
67
|
+
//
|
|
68
|
+
// 1. dragstart — remember the row, apply the drag ghost + dimming.
|
|
69
|
+
// 2. dragover — preventDefault so the browser offers a "move" cursor.
|
|
70
|
+
// 3. drop — compute the insertion index against the OTHER rows, move the
|
|
71
|
+
// row optimistically, then POST {prev_id, next_id, to_index}.
|
|
72
|
+
// 4. dragend — clear the dimming and the cached row.
|
|
73
|
+
//
|
|
74
|
+
// Keyboard: the grip is a real <button>, so it is tabbable, and ArrowUp /
|
|
75
|
+
// ArrowDown move the focused row one slot through exactly the same path.
|
|
76
|
+
// Native HTML5 DnD is mouse-only — without this the feature is unusable by
|
|
77
|
+
// keyboard, and the affordance would be a tab stop that does nothing.
|
|
78
|
+
export default class extends Controller {
|
|
79
|
+
static values = { urlTemplate: String, axis: { type: String, default: "vertical" } }
|
|
80
|
+
|
|
81
|
+
connect() {
|
|
82
|
+
this.draggedRow = null
|
|
83
|
+
|
|
84
|
+
this.onDragStart = this.#onDragStart.bind(this)
|
|
85
|
+
this.onDragOver = this.#onDragOver.bind(this)
|
|
86
|
+
this.onDragLeave = this.#onDragLeave.bind(this)
|
|
87
|
+
this.onDrop = this.#onDrop.bind(this)
|
|
88
|
+
this.onDragEnd = this.#onDragEnd.bind(this)
|
|
89
|
+
this.onKeyDown = this.#onKeyDown.bind(this)
|
|
90
|
+
|
|
91
|
+
this.element.addEventListener("dragstart", this.onDragStart)
|
|
92
|
+
this.element.addEventListener("dragover", this.onDragOver)
|
|
93
|
+
this.element.addEventListener("dragleave", this.onDragLeave)
|
|
94
|
+
this.element.addEventListener("drop", this.onDrop)
|
|
95
|
+
this.element.addEventListener("dragend", this.onDragEnd)
|
|
96
|
+
this.element.addEventListener("keydown", this.onKeyDown)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
disconnect() {
|
|
100
|
+
this.element.removeEventListener("dragstart", this.onDragStart)
|
|
101
|
+
this.element.removeEventListener("dragover", this.onDragOver)
|
|
102
|
+
this.element.removeEventListener("dragleave", this.onDragLeave)
|
|
103
|
+
this.element.removeEventListener("drop", this.onDrop)
|
|
104
|
+
this.element.removeEventListener("dragend", this.onDragEnd)
|
|
105
|
+
this.element.removeEventListener("keydown", this.onKeyDown)
|
|
106
|
+
// A drag interrupted by a Turbo navigation never reaches dragend.
|
|
107
|
+
hideInsertionMarker()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── drag lifecycle ─────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
#onDragStart(event) {
|
|
113
|
+
// The grip is the only draggable node, but the event still bubbles from
|
|
114
|
+
// whatever inside it the pointer grabbed (the icon's <svg>/<path>).
|
|
115
|
+
const grip = event.target.closest("[data-positioned-grip]")
|
|
116
|
+
if (!grip) return
|
|
117
|
+
|
|
118
|
+
const row = this.#rowFor(grip)
|
|
119
|
+
if (!row) return
|
|
120
|
+
|
|
121
|
+
this.draggedRow = row
|
|
122
|
+
// Dim the ROW, not the grip: the row is what the user is moving. The class
|
|
123
|
+
// is applied one frame late by beginDrag so the drag ghost is snapshotted
|
|
124
|
+
// at full opacity (see ../drag/sortable).
|
|
125
|
+
beginDrag(event, row, {
|
|
126
|
+
draggingClass: DRAGGING_CLASS,
|
|
127
|
+
payload: row.dataset.positionedRowId,
|
|
128
|
+
// The grip carries draggable="true", so without this the browser ghosts
|
|
129
|
+
// the grip alone and the row looks like it never left.
|
|
130
|
+
dragImage: row
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#onDragOver(event) {
|
|
135
|
+
// Without a cached row this is somebody else's drag (a kanban card, a file
|
|
136
|
+
// from the desktop). Leave it alone so the browser shows "no entry" rather
|
|
137
|
+
// than inviting a drop we would ignore.
|
|
138
|
+
if (!this.draggedRow) return
|
|
139
|
+
|
|
140
|
+
// A drop across positioning groups describes no position — the two records
|
|
141
|
+
// keep independent sequences — and the server refuses it. Withhold
|
|
142
|
+
// preventDefault so the browser shows "no entry" and the drop never fires,
|
|
143
|
+
// rather than letting the user commit to a move that silently snaps back.
|
|
144
|
+
if (!this.#sameGroupAs(event.target)) {
|
|
145
|
+
hideInsertionMarker()
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
event.preventDefault()
|
|
150
|
+
event.dataTransfer.dropEffect = "move"
|
|
151
|
+
|
|
152
|
+
// Same exclusion and same index function the drop uses, so the line the
|
|
153
|
+
// user aims at is the slot they get.
|
|
154
|
+
const others = this.#rows().filter(r => r !== this.draggedRow)
|
|
155
|
+
showInsertionMarker(others, this.#dropIndex(event, others), {
|
|
156
|
+
axis: this.axisValue === "horizontal" ? "horizontal" : "vertical",
|
|
157
|
+
container: this.element
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#onDragLeave(event) {
|
|
162
|
+
// Only when the pointer leaves the collection entirely — dragleave also
|
|
163
|
+
// fires crossing between rows inside it, and hiding on those would strobe.
|
|
164
|
+
if (event.relatedTarget && this.element.contains(event.relatedTarget)) return
|
|
165
|
+
hideInsertionMarker()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#onDrop(event) {
|
|
169
|
+
event.preventDefault()
|
|
170
|
+
// On release, not on dragend: the marker should not outlive the gesture
|
|
171
|
+
// while the POST is in flight.
|
|
172
|
+
hideInsertionMarker()
|
|
173
|
+
if (!this.draggedRow) return
|
|
174
|
+
|
|
175
|
+
const row = this.draggedRow
|
|
176
|
+
// Exclude the dragged row: an index computed over a list that still counts
|
|
177
|
+
// the row being moved is off by one for every downward move.
|
|
178
|
+
const others = this.#rows().filter(r => r !== row)
|
|
179
|
+
|
|
180
|
+
this.#applyMove(row, others, this.#dropIndex(event, others))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Where a drop at the cursor lands, in `items`' own order.
|
|
184
|
+
//
|
|
185
|
+
// A table stacks: one row per line, so the vertical midpoint test is the
|
|
186
|
+
// whole story. A grid WRAPS: cards flow left-to-right and then onto a new
|
|
187
|
+
// line, and reading that layout off clientY alone would collapse every card
|
|
188
|
+
// on a line into a single slot — the drop would land at the start of the row
|
|
189
|
+
// no matter which gap the user aimed at.
|
|
190
|
+
//
|
|
191
|
+
// But clientX alone is no better, because every visual row starts at the same
|
|
192
|
+
// left edge: x=250 is "after the third card" in row 1 and row 4 alike. So
|
|
193
|
+
// narrow to the row the cursor is over FIRST, then let the horizontal
|
|
194
|
+
// midpoint test choose the gap inside it.
|
|
195
|
+
#dropIndex(event, items) {
|
|
196
|
+
if (this.axisValue !== "horizontal") return computeDropIndex(event.clientY, items)
|
|
197
|
+
if (items.length === 0) return 0
|
|
198
|
+
|
|
199
|
+
const rows = this.#visualRows(items)
|
|
200
|
+
|
|
201
|
+
// Above the grid entirely → the very first slot. Below it → the last.
|
|
202
|
+
if (event.clientY < rows[0].top) return 0
|
|
203
|
+
const row = rows.find(r => event.clientY <= r.bottom)
|
|
204
|
+
if (!row) return items.length
|
|
205
|
+
|
|
206
|
+
return row.start + computeDropIndexHorizontal(event.clientX, items.slice(row.start, row.end))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Groups `items` into the lines the browser actually laid them out on, as
|
|
210
|
+
// {top, bottom, start, end} spans over `items`.
|
|
211
|
+
//
|
|
212
|
+
// Recovered from geometry rather than from a column count on purpose: the
|
|
213
|
+
// grid is responsive (1/2/3/4 columns by breakpoint) and a definition may pin
|
|
214
|
+
// its own, so the only thing that reliably knows how the cards wrapped is
|
|
215
|
+
// where they ended up. A new line starts wherever an item's top edge leaves
|
|
216
|
+
// the current one's — with a pixel of slack, since equal-height cards in a
|
|
217
|
+
// row can still differ by a sub-pixel.
|
|
218
|
+
#visualRows(items) {
|
|
219
|
+
const rows = []
|
|
220
|
+
|
|
221
|
+
items.forEach((item, i) => {
|
|
222
|
+
const rect = item.getBoundingClientRect()
|
|
223
|
+
const row = rows[rows.length - 1]
|
|
224
|
+
|
|
225
|
+
if (row && Math.abs(rect.top - row.top) <= 1) {
|
|
226
|
+
row.end = i + 1
|
|
227
|
+
row.bottom = Math.max(row.bottom, rect.bottom)
|
|
228
|
+
} else {
|
|
229
|
+
rows.push({ top: rect.top, bottom: rect.bottom, start: i, end: i + 1 })
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
return rows
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#onDragEnd(_event) {
|
|
237
|
+
if (!this.draggedRow) return
|
|
238
|
+
|
|
239
|
+
endDrag(this.draggedRow, { draggingClass: DRAGGING_CLASS })
|
|
240
|
+
this.draggedRow = null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── keyboard ───────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
#onKeyDown(event) {
|
|
246
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return
|
|
247
|
+
|
|
248
|
+
const grip = event.target.closest("[data-positioned-grip]")
|
|
249
|
+
if (!grip) return
|
|
250
|
+
|
|
251
|
+
const row = this.#rowFor(grip)
|
|
252
|
+
if (!row) return
|
|
253
|
+
|
|
254
|
+
// Only now: an arrow key that lands on nothing movable must keep its
|
|
255
|
+
// native meaning (scrolling the page).
|
|
256
|
+
event.preventDefault()
|
|
257
|
+
|
|
258
|
+
// Deliberately LINEAR, on a grid as much as on a table: it needs no drop
|
|
259
|
+
// index, because "one slot earlier / later in document order" is exactly
|
|
260
|
+
// what a single position attribute stores. Up/Down on a wrapped grid
|
|
261
|
+
// therefore means the previous/next CARD, not the card one line above —
|
|
262
|
+
// 2D navigation would move a card by a whole row per keypress and could
|
|
263
|
+
// not express the in-between slots at all.
|
|
264
|
+
const rows = this.#rows()
|
|
265
|
+
const from = rows.indexOf(row)
|
|
266
|
+
const to = event.key === "ArrowUp" ? from - 1 : from + 1
|
|
267
|
+
if (to < 0 || to >= rows.length) return
|
|
268
|
+
|
|
269
|
+
// With the row itself removed, "land at slot `to`" is the insertion index
|
|
270
|
+
// among the remaining rows in both directions: up → to, down → to.
|
|
271
|
+
const others = rows.filter(r => r !== row)
|
|
272
|
+
this.#applyMove(row, others, to)
|
|
273
|
+
|
|
274
|
+
// Moving a node in the DOM drops focus in most browsers, which would strand
|
|
275
|
+
// the user after a single keypress. The grip travels with the row, so it is
|
|
276
|
+
// still the right element to return to.
|
|
277
|
+
grip.focus()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── the move itself ────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
// Moves `row` to `index` among `others` (which must already exclude `row`),
|
|
283
|
+
// optimistically, then tells the server. Shared by drop and keyboard so the
|
|
284
|
+
// two can never compute neighbours differently.
|
|
285
|
+
#applyMove(row, others, index) {
|
|
286
|
+
const before = others[index] ?? null
|
|
287
|
+
const after = others[index - 1] ?? null
|
|
288
|
+
|
|
289
|
+
// The row is already exactly there — both neighbours unchanged. A drop that
|
|
290
|
+
// lands where it started is the commonest accidental drag there is, and
|
|
291
|
+
// posting it would write a position identical to the current one (or, in
|
|
292
|
+
// Mode B, run somebody else's renumbering block) for no reason at all.
|
|
293
|
+
if (row.nextElementSibling === before && row.previousElementSibling === after) return
|
|
294
|
+
|
|
295
|
+
// Where to put the row back if the server never confirms the move. Captured
|
|
296
|
+
// BEFORE the optimistic insert, as a sibling reference rather than an index
|
|
297
|
+
// — the row's own index is about to change. `nextSibling`, not
|
|
298
|
+
// `nextElementSibling`, so the row lands back on the same side of the
|
|
299
|
+
// whitespace text node it came from and the markup is byte-identical.
|
|
300
|
+
//
|
|
301
|
+
// (Unlike the kanban board, which this controller shares its drag core with,
|
|
302
|
+
// a reorder DOES re-parent the node. Kanban can afford to leave a rejected
|
|
303
|
+
// drop alone because native HTML5 DnD never moved its card; here, doing
|
|
304
|
+
// nothing leaves the user looking at an order that was never written.)
|
|
305
|
+
const restoreAnchor = row.nextSibling
|
|
306
|
+
|
|
307
|
+
row.parentElement.insertBefore(row, before)
|
|
308
|
+
|
|
309
|
+
this.#submit(row, restoreAnchor, {
|
|
310
|
+
// The ids of the row's VISIBLE neighbours. A blank one means "nothing on
|
|
311
|
+
// that side of my page" — the server treats that as a claim about the
|
|
312
|
+
// viewport, not about the positioning group, and looks the real boundary
|
|
313
|
+
// neighbour up itself (see PositionActions#resolve_position_boundaries).
|
|
314
|
+
prevId: after?.dataset.positionedRowId ?? "",
|
|
315
|
+
nextId: before?.dataset.positionedRowId ?? "",
|
|
316
|
+
toIndex: index
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// `row` is the already-moved element and `restoreAnchor` the sibling it sat
|
|
321
|
+
// in front of beforehand, so every path that does not end in the server's own
|
|
322
|
+
// truth can undo the optimistic move rather than leave it standing.
|
|
323
|
+
async #submit(row, restoreAnchor, { prevId, nextId, toIndex }) {
|
|
324
|
+
const recordId = row.dataset.positionedRowId
|
|
325
|
+
|
|
326
|
+
// window.location.search is LOAD-BEARING, not decoration. The endpoint
|
|
327
|
+
// re-renders through the ordinary index pipeline, so the collection's own
|
|
328
|
+
// query — search, filters, scope, sort, page, view — has to arrive in
|
|
329
|
+
// params. Without it a drop on page 3 of a filtered list reconciles as
|
|
330
|
+
// page 1 of an unfiltered one.
|
|
331
|
+
const url = this.urlTemplateValue.replace("__ID__", recordId) + window.location.search
|
|
332
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const response = await fetch(url, {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: {
|
|
338
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
339
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
340
|
+
"X-CSRF-Token": csrfToken
|
|
341
|
+
},
|
|
342
|
+
body: new URLSearchParams({
|
|
343
|
+
prev_id: prevId,
|
|
344
|
+
next_id: nextId,
|
|
345
|
+
to_index: toIndex
|
|
346
|
+
}),
|
|
347
|
+
credentials: "same-origin"
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// 204 = a clean drop. The optimistic DOM is already exactly right, so
|
|
351
|
+
// repainting would only cost a flash and the user's focus.
|
|
352
|
+
if (response.status === 204) return
|
|
353
|
+
|
|
354
|
+
// Everything else the server answers with is a turbo-stream that replaces
|
|
355
|
+
// the collection with its own truth — the row snaps back on a rejection,
|
|
356
|
+
// or settles into a rebalanced order. Only feed Turbo genuine streams: a
|
|
357
|
+
// 302 to a login page comes back as a 200 HTML document, and morphing
|
|
358
|
+
// THAT into the table paints the error page over the collection.
|
|
359
|
+
const contentType = response.headers.get("Content-Type") || ""
|
|
360
|
+
if (contentType.includes("text/vnd.turbo-stream.html") && window.Turbo) {
|
|
361
|
+
const focusedRowId = this.#focusedRowId()
|
|
362
|
+
window.Turbo.renderStreamMessage(await response.text())
|
|
363
|
+
// The stream replaced the rows, so a grip the user was tabbed to is
|
|
364
|
+
// gone. Put focus back on the same record's new grip.
|
|
365
|
+
if (focusedRowId) this.#restoreFocus(focusedRowId)
|
|
366
|
+
} else if (!response.ok) {
|
|
367
|
+
// A rejection with no collection to reconcile against — a denied
|
|
368
|
+
// `index?` answers 403 with no body on purpose, because the viewer may
|
|
369
|
+
// not see this listing at all. Nothing was written, so undo the move.
|
|
370
|
+
console.error(`[positioned] reposition rejected (${response.status}); reverting the move`)
|
|
371
|
+
this.#revertMove(row, restoreAnchor)
|
|
372
|
+
} else {
|
|
373
|
+
// 2xx but NOT a stream. Almost always an auth boundary that
|
|
374
|
+
// 302-redirected to a login page, which fetch followed transparently —
|
|
375
|
+
// so the POST did nothing at all.
|
|
376
|
+
console.warn("[positioned] reposition returned a non-stream response (session expired?); reverting the move")
|
|
377
|
+
this.#revertMove(row, restoreAnchor)
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
// Network failure, offline, aborted. The server never saw the drop.
|
|
381
|
+
console.error("[positioned] reposition request failed:", error)
|
|
382
|
+
this.#revertMove(row, restoreAnchor)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Undoes the optimistic move. Only meaningful while the row is still the node
|
|
387
|
+
// we moved: once a turbo-stream has replaced the collection the server's truth
|
|
388
|
+
// owns the DOM, and this instance's nodes are detached.
|
|
389
|
+
//
|
|
390
|
+
// A missing/relocated anchor falls back to appending, which is also what a
|
|
391
|
+
// null anchor legitimately means — the row was last before the move.
|
|
392
|
+
#revertMove(row, anchor) {
|
|
393
|
+
const parent = row.parentElement
|
|
394
|
+
if (!parent || !row.isConnected) return
|
|
395
|
+
|
|
396
|
+
// insertBefore drops focus in most browsers, and the keyboard path leaves
|
|
397
|
+
// the user tabbed to this very grip.
|
|
398
|
+
const refocus = row.contains(document.activeElement)
|
|
399
|
+
|
|
400
|
+
parent.insertBefore(row, anchor?.parentNode === parent ? anchor : null)
|
|
401
|
+
|
|
402
|
+
if (refocus) row.querySelector("[data-positioned-grip]")?.focus()
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
#rows() {
|
|
408
|
+
return [...this.element.querySelectorAll("[data-positioned-row-id]")]
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#rowFor(element) {
|
|
412
|
+
return element.closest("[data-positioned-row-id]")
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Whether the row under the cursor shares the dragged row's positioning
|
|
416
|
+
// group. An UNSCOPED resource emits no group attribute at all, so both sides
|
|
417
|
+
// read undefined and every row matches — the guard costs nothing there.
|
|
418
|
+
//
|
|
419
|
+
// Pointing at no row (the gutter below the last one, the wrapper's padding)
|
|
420
|
+
// counts as a match: the drop resolves against the collection's own ends,
|
|
421
|
+
// which are in the dragged row's group by construction.
|
|
422
|
+
#sameGroupAs(target) {
|
|
423
|
+
const row = this.#rowFor(target)
|
|
424
|
+
if (!row) return true
|
|
425
|
+
|
|
426
|
+
return row.dataset.positionedGroup === this.draggedRow.dataset.positionedGroup
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#focusedRowId() {
|
|
430
|
+
const grip = document.activeElement?.closest?.("[data-positioned-grip]")
|
|
431
|
+
return grip ? this.#rowFor(grip)?.dataset.positionedRowId : null
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Deferred a frame: renderStreamMessage resolves its render asynchronously,
|
|
435
|
+
// so the replacement rows are not in the document yet when it returns.
|
|
436
|
+
//
|
|
437
|
+
// Queried off `document`, NOT `this.element`. The stream replaces the contents
|
|
438
|
+
// of the collection wrapper (Page::Index.collection_dom_id), and this
|
|
439
|
+
// controller's element — the div around the <table>, or the grid div — is one
|
|
440
|
+
// of the nodes it discards. By the time this callback runs `this.element` is
|
|
441
|
+
// detached, so scoping the lookup to it finds nothing and focus is silently
|
|
442
|
+
// lost on exactly the path this method exists to cover: a keyboard move that
|
|
443
|
+
// triggered a rebalance. A fresh controller is already connected to the
|
|
444
|
+
// replacement; `document` is the only handle that spans both.
|
|
445
|
+
#restoreFocus(rowId) {
|
|
446
|
+
requestAnimationFrame(() => {
|
|
447
|
+
document
|
|
448
|
+
.querySelector(`[data-positioned-row-id="${CSS.escape(rowId)}"] [data-positioned-grip]`)
|
|
449
|
+
?.focus()
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -37,7 +37,10 @@ import AutosubmitController from "./autosubmit_controller.js"
|
|
|
37
37
|
import DirtyFormGuardController from "./dirty_form_guard_controller.js"
|
|
38
38
|
import WizardController from "./wizard_controller.js"
|
|
39
39
|
import KanbanController from "./kanban_controller.js"
|
|
40
|
+
import PositionedController from "./positioned_controller.js"
|
|
40
41
|
import CurrencyInputController from "./currency_input_controller.js"
|
|
42
|
+
import BreadcrumbsController from "./breadcrumbs_controller.js"
|
|
43
|
+
import RunProgressController from "./run_progress_controller.js"
|
|
41
44
|
|
|
42
45
|
export default function (application) {
|
|
43
46
|
// Register controllers here
|
|
@@ -79,5 +82,8 @@ export default function (application) {
|
|
|
79
82
|
application.register("dirty-form-guard", DirtyFormGuardController)
|
|
80
83
|
application.register("wizard", WizardController)
|
|
81
84
|
application.register("kanban", KanbanController)
|
|
85
|
+
application.register("positioned", PositionedController)
|
|
82
86
|
application.register("currency-input", CurrencyInputController)
|
|
87
|
+
application.register("breadcrumbs", BreadcrumbsController)
|
|
88
|
+
application.register("run-progress", RunProgressController)
|
|
83
89
|
}
|
|
@@ -206,6 +206,10 @@ export default class extends Controller {
|
|
|
206
206
|
this.menu.classList.remove('hidden')
|
|
207
207
|
this.menu.classList.add('block')
|
|
208
208
|
this.menu.removeAttribute('aria-hidden')
|
|
209
|
+
// Templates render aria-expanded="false" so the trigger is honest before
|
|
210
|
+
// hydration; from here on the controller owns it. Without this every
|
|
211
|
+
// dropdown in the app permanently claims to be collapsed.
|
|
212
|
+
this.triggerTarget.setAttribute('aria-expanded', 'true')
|
|
209
213
|
|
|
210
214
|
// Enable popper event listeners
|
|
211
215
|
this.popperInstance.setOptions((options) => ({
|
|
@@ -225,6 +229,7 @@ export default class extends Controller {
|
|
|
225
229
|
this.menu.classList.remove('block')
|
|
226
230
|
this.menu.classList.add('hidden')
|
|
227
231
|
this.menu.setAttribute('aria-hidden', 'true')
|
|
232
|
+
this.triggerTarget.setAttribute('aria-expanded', 'false')
|
|
228
233
|
|
|
229
234
|
// Disable popper event listeners
|
|
230
235
|
this.popperInstance.setOptions((options) => ({
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="run-progress"
|
|
4
|
+
//
|
|
5
|
+
// Re-fetches the enclosing turbo-frame on an interval so an interaction run's
|
|
6
|
+
// progress page updates itself. Polling rather than ActionCable: it needs no
|
|
7
|
+
// cable server, so it works on every deployment.
|
|
8
|
+
//
|
|
9
|
+
// The element carrying this controller lives INSIDE the frame it reloads, and
|
|
10
|
+
// the server only emits it while the run is in progress. So each refresh
|
|
11
|
+
// disconnects this instance and connects a new one — until the run finishes,
|
|
12
|
+
// when the replacement markup carries no controller at all and the polling
|
|
13
|
+
// stops. That is the whole stop condition: nothing here has to know what
|
|
14
|
+
// "finished" means, and no timer outlives the work it was watching.
|
|
15
|
+
//
|
|
16
|
+
// The swap is the stop condition, but it must not also be the only thing that
|
|
17
|
+
// KEEPS it going: a fetch that fails swaps nothing, so relying on the
|
|
18
|
+
// replacement to re-arm the timer means one dropped request freezes the
|
|
19
|
+
// progress page until the reader reloads it by hand. #refresh re-arms itself,
|
|
20
|
+
// and #disconnect cancels that timer the moment a successful swap happens.
|
|
21
|
+
export default class extends Controller {
|
|
22
|
+
static values = {
|
|
23
|
+
url: String,
|
|
24
|
+
interval: { type: Number, default: 2000 },
|
|
25
|
+
// Set by the server on the LAST poll only — see AsyncRunProgress#poll_attributes.
|
|
26
|
+
finished: Boolean,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
connect() {
|
|
30
|
+
// The run settled. Only this panel is inside the polled frame, so the fields
|
|
31
|
+
// beside it still show what they showed at dispatch; one reload catches them
|
|
32
|
+
// up. Not a poll — there is nothing left to watch, and the reloaded page
|
|
33
|
+
// carries no controller at all.
|
|
34
|
+
if (this.finishedValue) return this.reloadPage()
|
|
35
|
+
|
|
36
|
+
this.schedule()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
disconnect() {
|
|
40
|
+
if (this.timer) clearTimeout(this.timer)
|
|
41
|
+
this.timer = null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
schedule() {
|
|
45
|
+
this.timer = setTimeout(() => this.refresh(), this.intervalValue)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
reloadPage() {
|
|
49
|
+
// visit() rather than location.reload(), so Turbo treats it as a navigation
|
|
50
|
+
// and keeps the scroll position and cache behaviour of every other one.
|
|
51
|
+
window.Turbo?.visit(window.location.href, { action: "replace" })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
refresh() {
|
|
55
|
+
const frame = this.element.closest("turbo-frame")
|
|
56
|
+
// No enclosing frame means the markup is wrong, not that the run is over —
|
|
57
|
+
// deliberately not rescheduled, since retrying cannot fix a template.
|
|
58
|
+
if (!frame) return
|
|
59
|
+
|
|
60
|
+
// Before the fetch, not after: reload() is async, and a rejected request
|
|
61
|
+
// must still leave a timer armed to try again.
|
|
62
|
+
this.schedule()
|
|
63
|
+
|
|
64
|
+
// The frame is rendered WITHOUT a src (it arrives as part of the full page,
|
|
65
|
+
// so an eager src would cost a second request on every page load). Setting
|
|
66
|
+
// it is what triggers the first fetch; after that reload() re-uses it.
|
|
67
|
+
if (frame.src) {
|
|
68
|
+
frame.reload()
|
|
69
|
+
} else {
|
|
70
|
+
frame.src = this.urlValue
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|