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,92 @@
|
|
|
1
|
+
require "rodauth"
|
|
2
|
+
|
|
3
|
+
# Keeps each Rodauth configuration's session state independent of the others, so a
|
|
4
|
+
# person can be signed into more than one portal at a time.
|
|
5
|
+
#
|
|
6
|
+
# Rodauth calls +clear_session+ from +update_session+ on *every* login - including
|
|
7
|
+
# the +remember+ feature's +load_memory+ autologin - to defend against session
|
|
8
|
+
# fixation. rodauth-rails implements that as a full +reset_session+, which drops the
|
|
9
|
+
# entire Rails session rather than just the keys belonging to the configuration
|
|
10
|
+
# doing the login.
|
|
11
|
+
#
|
|
12
|
+
# In a Plutonium app that breaks multi-portal access. Each portal authenticates
|
|
13
|
+
# through its own configuration (+:user+, +:admin+, ...), so signing into one portal
|
|
14
|
+
# evicts every other portal's session. Worse, with +after_login { remember_login }+
|
|
15
|
+
# enabled the +load_memory+ calls in +RodauthApp#route+ then re-authenticate the
|
|
16
|
+
# evicted configuration from its remember cookie on the very next request - and
|
|
17
|
+
# evict the new one in turn. The last +load_memory+ in the route block wins
|
|
18
|
+
# permanently, so the other portal can never hold a session at all.
|
|
19
|
+
#
|
|
20
|
+
# This feature carries the *other* configurations' session entries across the
|
|
21
|
+
# reset. The Rails session id is still rotated and this configuration's own state is
|
|
22
|
+
# still dropped, so session fixation is still defeated; application session data is
|
|
23
|
+
# still cleared, exactly as before.
|
|
24
|
+
#
|
|
25
|
+
# Ownership is decided by +session_key_prefix+, so every configuration that wants to
|
|
26
|
+
# coexist must set one. A configuration without a prefix uses Rodauth's unprefixed
|
|
27
|
+
# default key names - the same names every other unprefixed configuration uses - so
|
|
28
|
+
# there is no way to tell its entries apart and nothing is carried for it.
|
|
29
|
+
module Rodauth
|
|
30
|
+
Feature.define(:session_isolation, :SessionIsolation) do
|
|
31
|
+
def clear_session
|
|
32
|
+
carried = sibling_session_data
|
|
33
|
+
super
|
|
34
|
+
carried.each { |key, value| session[key] = value }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# The session entries owned by the app's other Rodauth configurations.
|
|
40
|
+
#
|
|
41
|
+
# Keys are matched by prefix rather than by asking each configuration to
|
|
42
|
+
# enumerate them. Deriving them from method names (e.g. everything matching
|
|
43
|
+
# /_session_key\z/) would mean blind-sending methods that are not readers at
|
|
44
|
+
# all: +single_session+ exposes +reset_single_session_key+ and
|
|
45
|
+
# +update_single_session_key+ as public auth methods, both of which UPDATE the
|
|
46
|
+
# database with a fresh random key - signing the sibling out, the exact opposite
|
|
47
|
+
# of this feature's purpose.
|
|
48
|
+
def sibling_session_data
|
|
49
|
+
data = session.to_hash
|
|
50
|
+
|
|
51
|
+
sibling_rodauths.each_with_object({}) do |sibling, carried|
|
|
52
|
+
prefix = sibling.session_key_prefix.to_s
|
|
53
|
+
# Unprefixed sibling: its key names are indistinguishable from ours, and
|
|
54
|
+
# carrying them would restore our own pre-login auth state across the reset,
|
|
55
|
+
# defeating the session fixation protection.
|
|
56
|
+
next if prefix.empty?
|
|
57
|
+
|
|
58
|
+
keys = data.keys.select { |key| key.to_s.start_with?(prefix) }
|
|
59
|
+
# Courtesy for a hand-written config that sets an explicit `session_key`
|
|
60
|
+
# alongside its prefix: that value bypasses `convert_session_key` and so is
|
|
61
|
+
# unprefixed. Do NOT read this as endorsing that shape - it leaves the account
|
|
62
|
+
# id rotating separately from every other key, which is a crash waiting to
|
|
63
|
+
# happen (see the note on session_key_prefix in the auth guide). Generated
|
|
64
|
+
# configs set the prefix only, and for them this line never matches.
|
|
65
|
+
account_key = sibling.session_key.to_s
|
|
66
|
+
keys << account_key if data.key?(account_key)
|
|
67
|
+
|
|
68
|
+
keys.each do |key|
|
|
69
|
+
carried[key] = data[key] unless own_session_key?(key)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Guards against a misconfiguration in which a sibling shares our prefix: we must
|
|
75
|
+
# never restore our own entries, or the reset would be a no-op for us.
|
|
76
|
+
def own_session_key?(key)
|
|
77
|
+
key = key.to_s
|
|
78
|
+
return true if key == session_key.to_s
|
|
79
|
+
|
|
80
|
+
prefix = session_key_prefix.to_s
|
|
81
|
+
prefix.present? && key.start_with?(prefix)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Only base-Rodauth methods are called on siblings (+session_key_prefix+ and
|
|
85
|
+
# +session_key+), so configurations that do not enable this feature - or do not
|
|
86
|
+
# descend from the app's base plugin at all - still work.
|
|
87
|
+
def sibling_rodauths
|
|
88
|
+
names = scope.opts[:rodauths].keys - [self.class.configuration_name]
|
|
89
|
+
names.map { |name| scope.rodauth(name) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/rodauth/plugins.rb
CHANGED
data/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radioactive-labs/plutonium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/js/core.js",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"cssnano": "^7.0.2",
|
|
41
41
|
"esbuild": "^0.28.1",
|
|
42
42
|
"esbuild-plugin-manifest": "^1.0.3",
|
|
43
|
+
"feed": "^6.0.0",
|
|
43
44
|
"flowbite-typography": "^1.0.5",
|
|
44
45
|
"medium-zoom": "^1.1.0",
|
|
45
46
|
"mermaid": "^11.15.0",
|
data/plutonium.gemspec
CHANGED
|
@@ -12,6 +12,10 @@ Gem::Specification.new do |spec|
|
|
|
12
12
|
"definitions, and portals that make building complex apps faster. Built for the AI era with Claude Code skills."
|
|
13
13
|
spec.homepage = "https://radioactive-labs.github.io/plutonium-core/"
|
|
14
14
|
spec.license = "MIT"
|
|
15
|
+
# Ruby 3.2 is DEPRECATED and will be dropped in the next release — see the
|
|
16
|
+
# post_install_message. Held here for one more release so 3.2 users can
|
|
17
|
+
# receive the fixes in this one before support ends; raising it in the same
|
|
18
|
+
# release that ships them would strand those users on the old version.
|
|
15
19
|
spec.required_ruby_version = ">= 3.2.2"
|
|
16
20
|
|
|
17
21
|
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
@@ -19,21 +23,22 @@ Gem::Specification.new do |spec|
|
|
|
19
23
|
# owner changes). `gem push` will prompt for an OTP at release time.
|
|
20
24
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
21
25
|
|
|
26
|
+
# Prints on EVERY install: only notices still actionable for someone
|
|
27
|
+
# installing THIS version.
|
|
22
28
|
spec.post_install_message = <<~MSG
|
|
23
|
-
|
|
29
|
+
⚠️ Ruby 3.2 support ends in the NEXT release
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
`<entity>_scope_*` to `<entity>_scoped_*`.
|
|
31
|
+
This is the last Plutonium release that installs on Ruby 3.2.
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
Why: pagy patches CVE-2026-54659 (a path-traversal in
|
|
34
|
+
`Pagy::I18n.locale=`) only in >= 43.5.6, and pagy dropped Ruby 3.2 in
|
|
35
|
+
43.5.0. No pagy version is both patched and installable on 3.2, so
|
|
36
|
+
continuing to support it means shipping a known-vulnerable dependency.
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
params directly (e.g. in tests, custom redirects, or hand-written links),
|
|
34
|
-
update them to the new names.
|
|
38
|
+
Ruby 3.2 reached end-of-life in March 2026.
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
Action: upgrade to Ruby 3.3 or newer before your next Plutonium bump.
|
|
41
|
+
If you stay on 3.2, `bundle update plutonium` will hold you here.
|
|
37
42
|
MSG
|
|
38
43
|
|
|
39
44
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
@@ -53,9 +58,14 @@ Gem::Specification.new do |spec|
|
|
|
53
58
|
spec.require_paths = ["lib"]
|
|
54
59
|
|
|
55
60
|
spec.add_dependency "zeitwerk"
|
|
56
|
-
spec.add_dependency "rails", ">= 7.2"
|
|
61
|
+
spec.add_dependency "rails", ">= 7.2.3.1"
|
|
57
62
|
spec.add_dependency "csv" # CSV export; no longer a default gem on Ruby 3.4+
|
|
58
63
|
spec.add_dependency "listen", "~> 3.8"
|
|
64
|
+
# NOT pinned to >= 43.5.6 (which patches CVE-2026-54659) yet: that version
|
|
65
|
+
# requires Ruby >= 3.3, so pinning it here would silently drop Ruby 3.2 a
|
|
66
|
+
# release early. `~> 43.0` already resolves to a patched 43.6.x on any fresh
|
|
67
|
+
# install under Ruby 3.3+, so the exposure is limited to apps holding an old
|
|
68
|
+
# pagy in their lockfile. Pin it in the release that drops 3.2.
|
|
59
69
|
spec.add_dependency "pagy", "~> 43.0"
|
|
60
70
|
spec.add_dependency "rabl", "~> 0.17.0" # TODO: what to do with RABL
|
|
61
71
|
spec.add_dependency "semantic_range", "~> 3.0"
|
|
@@ -83,6 +93,14 @@ Gem::Specification.new do |spec|
|
|
|
83
93
|
spec.add_development_dependency "combustion"
|
|
84
94
|
spec.add_development_dependency "capybara"
|
|
85
95
|
spec.add_development_dependency "selenium-webdriver"
|
|
96
|
+
# Exercises the Mode B positioning escape hatch (position_on with a block)
|
|
97
|
+
# against a REAL third-party positioning gem, which is the only way the
|
|
98
|
+
# worked example in docs/reference/positioning.md can be kept honest.
|
|
99
|
+
# A development dependency declared here (rather than in the Gemfile) is
|
|
100
|
+
# inherited by every appraisal gemfile via `gemspec path: "../"`, so the
|
|
101
|
+
# dummy app's acts_as_list resource loads on rails-7, 8.0 and 8.1 alike —
|
|
102
|
+
# and never becomes a runtime dependency of the gem.
|
|
103
|
+
spec.add_development_dependency "acts_as_list"
|
|
86
104
|
|
|
87
105
|
# For more information and examples about making a new gem, check out our
|
|
88
106
|
# guide at: https://bundler.io/guides/creating_gem.html
|
data/src/css/components.css
CHANGED
|
@@ -803,6 +803,18 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
803
803
|
overscroll-behavior: contain;
|
|
804
804
|
}
|
|
805
805
|
|
|
806
|
+
/* Full visible viewport height.
|
|
807
|
+
100vh is the viewport as it would be with the browser's own chrome hidden,
|
|
808
|
+
so on a phone a 100vh box is taller than what the user can actually see and
|
|
809
|
+
its bottom edge sits under the URL bar or the gesture bar. dvh tracks the
|
|
810
|
+
visible area; 100vh stays as the fallback for engines without it. Same
|
|
811
|
+
reasoning as the cap on .icon-rail-flyout-inner above, which is where this
|
|
812
|
+
pattern is already in use. */
|
|
813
|
+
.pu-min-h-viewport {
|
|
814
|
+
min-height: 100vh;
|
|
815
|
+
min-height: 100dvh;
|
|
816
|
+
}
|
|
817
|
+
|
|
806
818
|
@keyframes pu-rail-flyout-in {
|
|
807
819
|
from { opacity: 0; transform: translateX(-4px); }
|
|
808
820
|
to { opacity: 1; transform: translateX(0); }
|
|
@@ -1059,7 +1071,7 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
1059
1071
|
|
|
1060
1072
|
/* Column highlighted as the current drop target */
|
|
1061
1073
|
.pu-kanban-drop-target {
|
|
1062
|
-
outline: 2px solid
|
|
1074
|
+
outline: 2px solid theme(colors.primary.500);
|
|
1063
1075
|
outline-offset: -2px;
|
|
1064
1076
|
border-radius: var(--pu-radius-md, 0.5rem);
|
|
1065
1077
|
}
|
|
@@ -1068,3 +1080,79 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
1068
1080
|
.pu-kanban-no-drop {
|
|
1069
1081
|
opacity: 0.45;
|
|
1070
1082
|
}
|
|
1083
|
+
|
|
1084
|
+
/* Where the drop will land. Shared by the kanban board and every positioned
|
|
1085
|
+
table/grid (src/js/drag/sortable.js). Parked on <body> and positioned in
|
|
1086
|
+
viewport coordinates, so it is never clipped by a scrolling column or an
|
|
1087
|
+
overflow-x table wrapper. pointer-events:none keeps it out of the way of the
|
|
1088
|
+
dragover hit-testing that positions it. */
|
|
1089
|
+
.pu-drag-marker {
|
|
1090
|
+
position: fixed;
|
|
1091
|
+
z-index: 60;
|
|
1092
|
+
display: none;
|
|
1093
|
+
pointer-events: none;
|
|
1094
|
+
background: theme(colors.primary.500);
|
|
1095
|
+
border-radius: var(--pu-radius-full, 9999px);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/* `between` is already centred in the gap the layout leaves, so it only pulls
|
|
1099
|
+
back by half the line's thickness. The two ENDS touch a single item and have
|
|
1100
|
+
to be pushed clear of it — off the top of the first, below the bottom of the
|
|
1101
|
+
last. Tune the end clearance with --pu-drag-marker-gap. */
|
|
1102
|
+
.pu-drag-marker[data-axis="vertical"] {
|
|
1103
|
+
height: 2px;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
.pu-drag-marker[data-axis="vertical"][data-edge="between"] {
|
|
1107
|
+
margin-top: -1px;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
.pu-drag-marker[data-axis="vertical"][data-edge="leading"] {
|
|
1111
|
+
margin-top: calc(-1 * var(--pu-drag-marker-gap, 4px));
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
.pu-drag-marker[data-axis="vertical"][data-edge="trailing"] {
|
|
1115
|
+
margin-top: var(--pu-drag-marker-gap, 4px);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
.pu-drag-marker[data-axis="horizontal"] {
|
|
1119
|
+
width: 2px;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
.pu-drag-marker[data-axis="horizontal"][data-edge="between"] {
|
|
1123
|
+
margin-left: -1px;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
.pu-drag-marker[data-axis="horizontal"][data-edge="leading"] {
|
|
1127
|
+
margin-left: calc(-1 * var(--pu-drag-marker-gap, 4px));
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
.pu-drag-marker[data-axis="horizontal"][data-edge="trailing"] {
|
|
1131
|
+
margin-left: var(--pu-drag-marker-gap, 4px);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/* Indeterminate progress for an async run with no denominator (opaque work).
|
|
1135
|
+
A short segment sweeping the track: the motion is what says "alive", which a
|
|
1136
|
+
static bar at any width cannot, and a full-width bar would misread as done. */
|
|
1137
|
+
.pu-run-progress-indeterminate {
|
|
1138
|
+
width: 33%;
|
|
1139
|
+
/* 1.4s must match AsyncRunProgress::SWEEP_DURATION_MS, which the server uses
|
|
1140
|
+
to compute the negative animation-delay that keeps the sweep continuous
|
|
1141
|
+
across polls. */
|
|
1142
|
+
animation: pu-run-progress-sweep 1.4s ease-in-out infinite;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
@keyframes pu-run-progress-sweep {
|
|
1146
|
+
0% { margin-left: -33%; }
|
|
1147
|
+
100% { margin-left: 100%; }
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1151
|
+
/* No sweep to convey liveness, so fall back to a full track rather than a
|
|
1152
|
+
segment frozen at an arbitrary position, which would read as a percentage. */
|
|
1153
|
+
.pu-run-progress-indeterminate {
|
|
1154
|
+
width: 100%;
|
|
1155
|
+
animation: none;
|
|
1156
|
+
opacity: 0.6;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
data/src/css/slim_select.css
CHANGED
|
@@ -159,10 +159,30 @@
|
|
|
159
159
|
declare its own color rule. Specific rules below still win. */
|
|
160
160
|
color: var(--pu-text);
|
|
161
161
|
box-shadow: var(--pu-shadow-md);
|
|
162
|
+
/* pu-input (copied onto the panel, see the guard below) would round all
|
|
163
|
+
four corners. Keep the panel square by default — the open-above/below
|
|
164
|
+
rules add back the rounding on the outer edge only. */
|
|
165
|
+
border-radius: 0;
|
|
162
166
|
transform: scaleY(0);
|
|
163
167
|
transform-origin: top;
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
/* SlimSelect copies the <select>'s classes onto both .ss-main and .ss-content.
|
|
171
|
+
pu-input / pu-input-(in)valid are trigger styling — on the panel their
|
|
172
|
+
translucent validation tints replace the surface background (worst in dark
|
|
173
|
+
mode, where `.dark .pu-input-invalid` outranks `.ss-content` and the panel
|
|
174
|
+
turns see-through). Re-assert the panel chrome at higher specificity. */
|
|
175
|
+
.ss-content.pu-input,
|
|
176
|
+
.ss-content.pu-input-invalid,
|
|
177
|
+
.ss-content.pu-input-valid,
|
|
178
|
+
.dark .ss-content.pu-input,
|
|
179
|
+
.dark .ss-content.pu-input-invalid,
|
|
180
|
+
.dark .ss-content.pu-input-valid {
|
|
181
|
+
background-color: var(--pu-surface);
|
|
182
|
+
border-color: var(--pu-border);
|
|
183
|
+
color: var(--pu-text);
|
|
184
|
+
}
|
|
185
|
+
|
|
166
186
|
.ss-content.ss-relative {
|
|
167
187
|
@apply relative h-full;
|
|
168
188
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="breadcrumbs"
|
|
4
|
+
//
|
|
5
|
+
// Folds the middle of the trail into an overflow menu, but only as far as it
|
|
6
|
+
// has to: segments are measured and dropped left-to-right until the trail fits
|
|
7
|
+
// on one line. The dashboard link and the last segment always survive.
|
|
8
|
+
//
|
|
9
|
+
// This is deliberately width-driven rather than breakpoint-driven — a trail
|
|
10
|
+
// with one short segment fits fine on a phone, and a trail with a long record
|
|
11
|
+
// title can overflow a laptop. A media query can't tell those apart.
|
|
12
|
+
//
|
|
13
|
+
// Every segment is `shrink-0` so measurements reflect natural widths and real
|
|
14
|
+
// overflow is detectable. Only once everything foldable is folded do we let the
|
|
15
|
+
// final segment shrink and ellipsize, as a last resort.
|
|
16
|
+
export default class extends Controller {
|
|
17
|
+
static targets = ["list", "item", "last", "overflow", "menuItem"]
|
|
18
|
+
|
|
19
|
+
connect() {
|
|
20
|
+
// The dropdown controller teleports its menu to <body> while open, which
|
|
21
|
+
// moves these rows out of our scope — hold direct references so a resize
|
|
22
|
+
// mid-open doesn't throw.
|
|
23
|
+
this.menuItems = this.menuItemTargets
|
|
24
|
+
|
|
25
|
+
this.observer = new ResizeObserver(() => this.reflow())
|
|
26
|
+
this.observer.observe(this.element)
|
|
27
|
+
this.reflow()
|
|
28
|
+
|
|
29
|
+
// Web fonts land after first paint and change every measurement.
|
|
30
|
+
document.fonts?.ready.then(() => this.reflow())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
disconnect() {
|
|
34
|
+
this.observer.disconnect()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
reflow() {
|
|
38
|
+
// `fonts.ready` can resolve after a Turbo navigation has already torn this
|
|
39
|
+
// element out, where every measurement reads 0 and we would "helpfully"
|
|
40
|
+
// unfold the whole trail in a detached tree.
|
|
41
|
+
if (!this.element.isConnected || !this.hasListTarget) return
|
|
42
|
+
|
|
43
|
+
// Reset to "everything inline" so we measure natural widths, not the
|
|
44
|
+
// previous pass's decisions. Nothing paints mid-reflow, so hiding the
|
|
45
|
+
// control here is invisible even when it ends up shown again below.
|
|
46
|
+
this.itemTargets.forEach((el) => this.#show(el))
|
|
47
|
+
if (this.hasOverflowTarget) this.#hide(this.overflowTarget)
|
|
48
|
+
if (this.hasLastTarget) this.lastTarget.classList.add("shrink-0")
|
|
49
|
+
|
|
50
|
+
if (this.#overflows()) {
|
|
51
|
+
// A trail with a single segment has nothing foldable and so no control.
|
|
52
|
+
if (this.hasOverflowTarget) {
|
|
53
|
+
// The control takes room too, so it has to be in place before we
|
|
54
|
+
// measure whether folding a given segment was enough.
|
|
55
|
+
this.#show(this.overflowTarget)
|
|
56
|
+
|
|
57
|
+
for (const item of this.itemTargets) {
|
|
58
|
+
if (!this.#overflows()) break
|
|
59
|
+
this.#hide(item)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Nothing left to fold and it still doesn't fit: let the last segment
|
|
64
|
+
// ellipsize rather than push the trail off-screen.
|
|
65
|
+
if (this.#overflows() && this.hasLastTarget) {
|
|
66
|
+
this.lastTarget.classList.remove("shrink-0")
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.#syncMenu()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Measured on the nav rather than the <ol>, because the nav is the element
|
|
74
|
+
// that actually clips (`overflow-hidden`). A non-scrolling box reports
|
|
75
|
+
// overflow inconsistently once direction is rtl; the clipping box doesn't.
|
|
76
|
+
#overflows() {
|
|
77
|
+
return this.element.scrollWidth > this.element.clientWidth
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#show(el) {
|
|
81
|
+
el.classList.remove("hidden")
|
|
82
|
+
el.classList.add("flex")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#hide(el) {
|
|
86
|
+
el.classList.remove("flex")
|
|
87
|
+
el.classList.add("hidden")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A menu row is shown exactly when its inline twin has been folded away.
|
|
91
|
+
#syncMenu() {
|
|
92
|
+
if (!this.hasOverflowTarget) return
|
|
93
|
+
|
|
94
|
+
const folded = this.itemTargets.map((el) => el.classList.contains("hidden"))
|
|
95
|
+
|
|
96
|
+
this.menuItems.forEach((row, i) => {
|
|
97
|
+
row.classList.toggle("hidden", !folded[i])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (!folded.some(Boolean)) this.#closeOverflowMenu()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The trail grew back far enough that the control is gone. Its menu may
|
|
104
|
+
// still be open — and teleported to <body>, so hiding the control does not
|
|
105
|
+
// hide the menu. Popper would go on positioning it against a display:none
|
|
106
|
+
// trigger, whose rect is all zeros, parking the menu in the viewport corner.
|
|
107
|
+
#closeOverflowMenu() {
|
|
108
|
+
const host = this.overflowTarget.querySelector('[data-controller~="resource-drop-down"]')
|
|
109
|
+
const dropdown = this.application.getControllerForElementAndIdentifier(host, "resource-drop-down")
|
|
110
|
+
if (dropdown?.visible) dropdown.hide()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -56,9 +56,17 @@ export default class extends Controller {
|
|
|
56
56
|
const baseUrl = button.dataset.bulkActionUrl
|
|
57
57
|
const actionName = button.dataset.bulkActionName
|
|
58
58
|
|
|
59
|
-
// Update URL with selected IDs
|
|
59
|
+
// Update URL with selected IDs.
|
|
60
|
+
//
|
|
61
|
+
// Merged into whatever query the server already put on the URL, not
|
|
62
|
+
// appended with a bare "?" — that discarded every existing param (and
|
|
63
|
+
// produced a malformed second "?" when there was one). The toolbar carries
|
|
64
|
+
// return_to there, which is what sends the user back to this index rather
|
|
65
|
+
// than stranding them on whatever the action redirects to.
|
|
60
66
|
if (baseUrl) {
|
|
61
|
-
|
|
67
|
+
const [path, query] = baseUrl.split("?")
|
|
68
|
+
const merged = [query, idsParam].filter(Boolean).join("&")
|
|
69
|
+
button.href = merged ? `${path}?${merged}` : path
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// Show/hide button based on whether action is allowed for all selected records
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
import { morphTurboFrameElements } from "@hotwired/turbo"
|
|
3
|
+
import { beginDrag, computeDropIndex, endDrag, hideInsertionMarker, showInsertionMarker } from "../drag/sortable"
|
|
3
4
|
|
|
4
5
|
// Connects to data-controller="kanban"
|
|
5
6
|
//
|
|
@@ -435,13 +436,14 @@ export default class extends Controller {
|
|
|
435
436
|
if (!card) return
|
|
436
437
|
|
|
437
438
|
this.draggedCard = card
|
|
438
|
-
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
439
|
+
// Generic mechanics (payload + deferred dragging class) live in
|
|
440
|
+
// ../drag/sortable so the table/grid reorder controller shares them.
|
|
441
|
+
// The payload is the record id, so a card dropped outside the board still
|
|
442
|
+
// carries data natively (we won't handle it, but no error).
|
|
443
|
+
beginDrag(event, card, {
|
|
444
|
+
draggingClass: "pu-kanban-dragging",
|
|
445
|
+
payload: card.dataset.kanbanRecordId
|
|
446
|
+
})
|
|
445
447
|
|
|
446
448
|
// Mark columns that would reject a drop from this card's source column.
|
|
447
449
|
this.#applyDropHints(card.dataset.kanbanColumnKey)
|
|
@@ -459,6 +461,19 @@ export default class extends Controller {
|
|
|
459
461
|
event.preventDefault()
|
|
460
462
|
event.dataTransfer.dropEffect = "move"
|
|
461
463
|
this.#highlightColumn(column)
|
|
464
|
+
|
|
465
|
+
// The column outline says WHICH column; this says which SLOT within it.
|
|
466
|
+
// Same exclusion and same index function #onDrop uses, so the line marks
|
|
467
|
+
// the position the drop will actually take.
|
|
468
|
+
const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")]
|
|
469
|
+
.filter(c => c !== this.draggedCard)
|
|
470
|
+
showInsertionMarker(existingCards, computeDropIndex(event.clientY, existingCards), {
|
|
471
|
+
axis: "vertical",
|
|
472
|
+
container: column,
|
|
473
|
+
// Cards sit further apart than table rows, so the table's clearance reads
|
|
474
|
+
// as cramped against a card's edge here.
|
|
475
|
+
gap: "10px"
|
|
476
|
+
})
|
|
462
477
|
}
|
|
463
478
|
|
|
464
479
|
#onDragLeave(event) {
|
|
@@ -467,12 +482,16 @@ export default class extends Controller {
|
|
|
467
482
|
// the board wrapper when crossing the edge.
|
|
468
483
|
if (!this.element.contains(event.relatedTarget)) {
|
|
469
484
|
this.#clearHighlights()
|
|
485
|
+
hideInsertionMarker()
|
|
470
486
|
}
|
|
471
487
|
}
|
|
472
488
|
|
|
473
489
|
async #onDrop(event) {
|
|
474
490
|
event.preventDefault()
|
|
475
491
|
this.#clearHighlights()
|
|
492
|
+
// On release, not on dragend: the marker should not outlive the gesture
|
|
493
|
+
// while the move is in flight.
|
|
494
|
+
hideInsertionMarker()
|
|
476
495
|
|
|
477
496
|
if (!this.draggedCard) return
|
|
478
497
|
|
|
@@ -494,7 +513,9 @@ export default class extends Controller {
|
|
|
494
513
|
const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")]
|
|
495
514
|
.filter(c => c !== this.draggedCard)
|
|
496
515
|
|
|
497
|
-
|
|
516
|
+
// 0-based insertion index from the cursor's y-position against each card's
|
|
517
|
+
// vertical midpoint (shared geometry — see ../drag/sortable).
|
|
518
|
+
const toIndex = computeDropIndex(event.clientY, existingCards)
|
|
498
519
|
|
|
499
520
|
// Columns that declare an enter_interaction, on a CROSS-column drop:
|
|
500
521
|
// • immediate (input-less) interaction → commit directly via the normal
|
|
@@ -616,7 +637,7 @@ export default class extends Controller {
|
|
|
616
637
|
this.#clearHighlights()
|
|
617
638
|
this.#clearDropHints()
|
|
618
639
|
if (this.draggedCard) {
|
|
619
|
-
this.draggedCard
|
|
640
|
+
endDrag(this.draggedCard, { draggingClass: "pu-kanban-dragging" })
|
|
620
641
|
this.draggedCard = null
|
|
621
642
|
}
|
|
622
643
|
}
|
|
@@ -693,18 +714,6 @@ export default class extends Controller {
|
|
|
693
714
|
|
|
694
715
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
695
716
|
|
|
696
|
-
// Returns the 0-based insertion index within the destination column by
|
|
697
|
-
// comparing the cursor y-position against each card's vertical midpoint.
|
|
698
|
-
// The card is inserted before the first card whose midpoint is below the
|
|
699
|
-
// cursor, or appended after all cards if the cursor is below every midpoint.
|
|
700
|
-
#computeDropIndex(clientY, cards) {
|
|
701
|
-
for (let i = 0; i < cards.length; i++) {
|
|
702
|
-
const rect = cards[i].getBoundingClientRect()
|
|
703
|
-
if (clientY < rect.top + rect.height / 2) return i
|
|
704
|
-
}
|
|
705
|
-
return cards.length
|
|
706
|
-
}
|
|
707
|
-
|
|
708
717
|
#highlightColumn(column) {
|
|
709
718
|
this.columnTargets.forEach(c => {
|
|
710
719
|
c.classList.toggle("pu-kanban-drop-target", c === column)
|