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
|
@@ -11,6 +11,16 @@ module Plutonium
|
|
|
11
11
|
#
|
|
12
12
|
# The `scoped_record_of_type` method checks both contexts and ensures type safety.
|
|
13
13
|
#
|
|
14
|
+
# == Precondition
|
|
15
|
+
#
|
|
16
|
+
# These methods read the controller the interaction is rendering in, so
|
|
17
|
+
# they require a Plutonium controller: `current_scoped_entity` needs
|
|
18
|
+
# Core::Controllers::EntityScoping (which Plutonium::Core::Controller
|
|
19
|
+
# includes, portal or not), and `current_parent` needs
|
|
20
|
+
# Plutonium::Resource::Controller. Called anywhere else they raise
|
|
21
|
+
# NoMethodError, deliberately — see #current_parent. An interaction that
|
|
22
|
+
# can run outside a resource controller must not ask for a parent.
|
|
23
|
+
#
|
|
14
24
|
# @example Using in an interaction
|
|
15
25
|
# class MyInteraction < Plutonium::Resource::Interaction
|
|
16
26
|
# include Plutonium::Interaction::Concerns::Scoping
|
|
@@ -33,26 +43,77 @@ module Plutonium
|
|
|
33
43
|
#
|
|
34
44
|
# @param klass [Class] the expected model class
|
|
35
45
|
# @return [Object, nil] the scoped record if found and type matches, nil otherwise
|
|
46
|
+
# Asks for the parent only if the entity did not already answer.
|
|
47
|
+
#
|
|
48
|
+
# "Prefers entity over parent" has to mean the parent is not consulted at
|
|
49
|
+
# all when the entity matches, or the preference is only about which
|
|
50
|
+
# answer wins rather than which questions get asked. The array form
|
|
51
|
+
# evaluated both before looking at either, so a caller that wanted the
|
|
52
|
+
# TENANT still walked view_context.controller.helpers for a parent whose
|
|
53
|
+
# answer it then discarded.
|
|
54
|
+
#
|
|
55
|
+
# It also keeps the precondition above honest. #current_parent no longer
|
|
56
|
+
# rescues, so it raises wherever Plutonium::Resource::Controller is not
|
|
57
|
+
# in play — and every caller today is an interaction, which always runs
|
|
58
|
+
# under one. Nothing reaches that raise now; asking lazily is what stops
|
|
59
|
+
# a tenant-only caller from being the one that finds it.
|
|
36
60
|
def scoped_record_of_type(klass)
|
|
37
|
-
|
|
61
|
+
entity = current_scoped_entity
|
|
62
|
+
return entity if entity.is_a?(klass)
|
|
63
|
+
|
|
64
|
+
parent = current_parent
|
|
65
|
+
parent if parent.is_a?(klass)
|
|
38
66
|
end
|
|
39
67
|
|
|
40
68
|
# Returns the parent record from the controller (nested routes).
|
|
41
69
|
#
|
|
42
|
-
#
|
|
70
|
+
# Reached through +helpers+, not off the controller: both readers are
|
|
71
|
+
# PRIVATE controller methods published with +helper_method+ (see
|
|
72
|
+
# Plutonium::Resource::Controller and Core::Controllers::EntityScoping),
|
|
73
|
+
# so calling them on the controller raises NoMethodError.
|
|
74
|
+
#
|
|
75
|
+
# Which is exactly what it should do. Neither reader rescues, because the
|
|
76
|
+
# two answers must never collapse: "there is no parent" and "I could not
|
|
77
|
+
# determine the parent" look identical as nil, and for the tenant below
|
|
78
|
+
# that identity is a fail-OPEN — a nil tenant drops the entity filter
|
|
79
|
+
# rather than narrowing to one. A rescue here bought silence about a
|
|
80
|
+
# broken call for as long as it shipped; the next visibility change or
|
|
81
|
+
# typo would buy the same silence again.
|
|
82
|
+
#
|
|
83
|
+
# nil here means the route is not nested, which is the controller
|
|
84
|
+
# answering rather than an error being swallowed.
|
|
85
|
+
#
|
|
86
|
+
# @return [Object, nil] the current parent, or nil on a non-nested route
|
|
43
87
|
def current_parent
|
|
44
|
-
view_context.controller.current_parent
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
view_context.controller.helpers.current_parent
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The association a nested child hangs off its parent, e.g. :comments for
|
|
92
|
+
# /posts/5/comments.
|
|
93
|
+
#
|
|
94
|
+
# Reached through +helpers+ and unrescued for the same reasons as
|
|
95
|
+
# #current_parent above, and always answered together with it: a policy
|
|
96
|
+
# needs both halves or neither (Policy#default_relation_scope raises on
|
|
97
|
+
# one alone).
|
|
98
|
+
#
|
|
99
|
+
# @return [Symbol, nil] nil on a non-nested route
|
|
100
|
+
def current_nested_association
|
|
101
|
+
view_context.controller.helpers.current_nested_association
|
|
47
102
|
end
|
|
48
103
|
|
|
49
104
|
# Returns the entity record from the controller (portal multi-tenancy).
|
|
50
105
|
#
|
|
51
|
-
#
|
|
106
|
+
# nil means the portal has NO tenant, never that one could not be found.
|
|
107
|
+
#
|
|
108
|
+
# @return [Object, nil] the current scoped entity, or nil in an un-scoped portal
|
|
52
109
|
def current_scoped_entity
|
|
53
|
-
view_context.controller
|
|
54
|
-
|
|
55
|
-
|
|
110
|
+
controller = view_context.controller
|
|
111
|
+
# Asking an un-scoped portal for its entity raises NotImplementedError
|
|
112
|
+
# by design; "there is no tenant" is the honest answer for a caller
|
|
113
|
+
# that only wants to know which one it is in.
|
|
114
|
+
return nil unless controller.scoped_to_entity?
|
|
115
|
+
|
|
116
|
+
controller.helpers.current_scoped_entity
|
|
56
117
|
end
|
|
57
118
|
|
|
58
119
|
# Returns the appropriate parent for URL generation.
|
|
@@ -17,15 +17,23 @@ module Plutonium
|
|
|
17
17
|
redirect_options = @options
|
|
18
18
|
|
|
19
19
|
controller.instance_eval do
|
|
20
|
-
# Preserve the request format unless explicitly specified
|
|
20
|
+
# Preserve the request format unless explicitly specified.
|
|
21
|
+
#
|
|
22
|
+
# Skipped for a target that is ALREADY A URL. There is nothing to
|
|
23
|
+
# merge a format into, and url_for takes exactly one argument — so
|
|
24
|
+
# appending the options hash raises ArgumentError rather than
|
|
25
|
+
# producing a formatted URL. A caller handing over a complete URL has
|
|
26
|
+
# already decided what it points at.
|
|
27
|
+
fully_formed_url = redirect_args.size == 1 && redirect_args.first.is_a?(String)
|
|
28
|
+
|
|
21
29
|
url_options = redirect_args.last.is_a?(Hash) ? redirect_args.last : {}
|
|
22
|
-
if !url_options.key?(:format) && request.format.symbol != :html
|
|
30
|
+
if !fully_formed_url && !url_options.key?(:format) && request.format.symbol != :html
|
|
23
31
|
url_options = url_options.merge(format: request.format.symbol)
|
|
24
32
|
redirect_args = [*redirect_args[0...-1], url_options] if redirect_args.last.is_a?(Hash)
|
|
25
33
|
redirect_args = [*redirect_args, url_options] unless redirect_args.last.is_a?(Hash)
|
|
26
34
|
end
|
|
27
35
|
|
|
28
|
-
url = url_for(*redirect_args)
|
|
36
|
+
url = fully_formed_url ? redirect_args.first : url_for(*redirect_args)
|
|
29
37
|
|
|
30
38
|
respond_to do |format|
|
|
31
39
|
format.turbo_stream do
|
|
@@ -35,6 +35,20 @@ module Plutonium
|
|
|
35
35
|
# Resolves the board's effective show_in, falling back to the definition's
|
|
36
36
|
# `show_in` when the board doesn't override it. Pass the resource definition.
|
|
37
37
|
def show_in_for(definition) = @show_in || definition.show_in
|
|
38
|
+
|
|
39
|
+
# The board's positioning strategy: its own `position_on` if the block
|
|
40
|
+
# declared one, else the definition's, else the historic default.
|
|
41
|
+
#
|
|
42
|
+
# Resolved LAZILY, not at build time: `kanban` eagerly compiles the board
|
|
43
|
+
# at class-load (definition/index_views.rb), so a board built before a
|
|
44
|
+
# later `position_on` line would silently miss it — making the declaration
|
|
45
|
+
# order-dependent in the class body.
|
|
46
|
+
#
|
|
47
|
+
# Takes the resource definition, exactly like #show_in_for above.
|
|
48
|
+
def position_config_for(definition)
|
|
49
|
+
@position_config || definition.defined_position_config ||
|
|
50
|
+
Plutonium::Positioning::Config.default
|
|
51
|
+
end
|
|
38
52
|
end
|
|
39
53
|
end
|
|
40
54
|
end
|
|
@@ -32,8 +32,10 @@ module Plutonium
|
|
|
32
32
|
# `resource` attribute), never collection/bulk-shaped (`resources`). Reject
|
|
33
33
|
# anything else at definition time so a mis-shaped interaction can't (a) blow
|
|
34
34
|
# up at drop time on the `resource=` assignment, or (b) get auto-classified
|
|
35
|
-
# by Action::Interactive::Factory as a bulk action
|
|
36
|
-
#
|
|
35
|
+
# by Action::Interactive::Factory as a bulk action. The bulk bars now
|
|
36
|
+
# filter `hidden?` themselves, so (b) is defence-in-depth — but keep the
|
|
37
|
+
# guard: failing at definition time with a message that names the fix
|
|
38
|
+
# beats failing at drop time on the `resource=` assignment.
|
|
37
39
|
if enter_interaction && !enter_interaction.attribute_names.map(&:to_sym).include?(:resource)
|
|
38
40
|
raise ArgumentError, "enter_interaction: #{enter_interaction} must operate on a single record (declare `attribute :resource`); collection/bulk interactions cannot be used as an enter_interaction."
|
|
39
41
|
end
|
data/lib/plutonium/kanban/dsl.rb
CHANGED
|
@@ -15,7 +15,10 @@ module Plutonium
|
|
|
15
15
|
@card_fields = nil
|
|
16
16
|
@per_column = nil
|
|
17
17
|
@realtime = false
|
|
18
|
-
|
|
18
|
+
# nil means "not declared" so the board can inherit the definition's
|
|
19
|
+
# position_on. Resolution — including the historic Config.default
|
|
20
|
+
# fallback — moves to Board#position_config_for.
|
|
21
|
+
@position_config = nil
|
|
19
22
|
@lazy = true
|
|
20
23
|
@show_in = nil
|
|
21
24
|
end
|
|
@@ -2,33 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Plutonium
|
|
4
4
|
module Kanban
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# The two primitives every kanban rendering path shares: which columns a
|
|
6
|
+
# board has, and how a column's scope narrows a relation.
|
|
7
|
+
#
|
|
8
|
+
# There is deliberately no `call` that groups a whole relation in one go.
|
|
9
|
+
# One existed and had no callers: KanbanActions renders columns
|
|
10
|
+
# independently — each is its own lazy turbo-frame with its own per_column
|
|
11
|
+
# cap and count — so a whole-board grouper only ever duplicated that logic
|
|
12
|
+
# while quietly counting and limiting every column on every request.
|
|
7
13
|
module Grouping
|
|
8
14
|
module_function
|
|
9
15
|
|
|
10
|
-
# Returns [{column:, cards: [records], total: Integer}, ...] in column order.
|
|
11
|
-
def call(board:, relation:, context:)
|
|
12
|
-
columns = resolve_columns(board, context)
|
|
13
|
-
pos = board.position_config
|
|
14
|
-
columns.map do |col|
|
|
15
|
-
scoped = apply_scope(relation, col.scope)
|
|
16
|
-
ordered = pos.order(scoped)
|
|
17
|
-
if board.per_column
|
|
18
|
-
total = ordered.count
|
|
19
|
-
cards = ordered.limit(board.per_column).to_a
|
|
20
|
-
else
|
|
21
|
-
cards = ordered.to_a
|
|
22
|
-
total = cards.size
|
|
23
|
-
end
|
|
24
|
-
{column: col, cards: cards, total: total}
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
16
|
# Resolves the column list from a board. For dynamic boards, evaluates
|
|
29
17
|
# the columns_block against the context (which exposes current_user,
|
|
30
|
-
# params, etc. via delegation to view_context).
|
|
31
|
-
# handler) can call Grouping.resolve_columns(board, context) directly.
|
|
18
|
+
# params, etc. via delegation to view_context).
|
|
32
19
|
def resolve_columns(board, context)
|
|
33
20
|
return board.columns unless board.dynamic?
|
|
34
21
|
Array(context.instance_exec(&board.columns_block)).flatten
|
|
@@ -4,72 +4,12 @@ require "plutonium/positioning"
|
|
|
4
4
|
|
|
5
5
|
module Plutonium
|
|
6
6
|
module Kanban
|
|
7
|
+
# Kanban's positioning strategy is the framework-wide one — see
|
|
8
|
+
# Plutonium::Positioning::Config. This alias preserves the original
|
|
9
|
+
# namespace so `position_on` and every existing reference keep working.
|
|
7
10
|
module Positioning
|
|
8
|
-
|
|
9
|
-
Move =
|
|
10
|
-
|
|
11
|
-
# Strategy configuration object created by the `position_on` DSL.
|
|
12
|
-
#
|
|
13
|
-
# Three modes:
|
|
14
|
-
# Mode A (:delegate) — delegate reposition! to Plutonium::Positioning concern
|
|
15
|
-
# Mode B (:block) — call a user-supplied block with a Move
|
|
16
|
-
# Mode C (:disabled) — no ordering; relation returned unchanged
|
|
17
|
-
class Config
|
|
18
|
-
# Mode A, default attribute :position
|
|
19
|
-
def self.default
|
|
20
|
-
new(:delegate, :position, nil)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Mode A, custom attribute
|
|
24
|
-
def self.attribute(attr)
|
|
25
|
-
new(:delegate, attr.to_sym, nil)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Mode B — orders by attr, write delegated to block
|
|
29
|
-
def self.with_block(attr, block)
|
|
30
|
-
new(:block, attr.to_sym, block)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Mode C — disabled
|
|
34
|
-
def self.disabled
|
|
35
|
-
new(:disabled, nil, nil)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
attr_reader :attribute
|
|
39
|
-
|
|
40
|
-
def initialize(mode, attribute, block)
|
|
41
|
-
@mode = mode
|
|
42
|
-
@attribute = attribute
|
|
43
|
-
@block = block
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def disabled?
|
|
47
|
-
@mode == :disabled
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Apply positional ordering to a relation.
|
|
51
|
-
# Mode A/B: relation.reorder(attribute)
|
|
52
|
-
# Mode C: return relation unchanged
|
|
53
|
-
def order(relation)
|
|
54
|
-
return relation if disabled?
|
|
55
|
-
relation.reorder(@attribute)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Persist the new position for a dropped record.
|
|
59
|
-
# Mode A: delegate to record.reposition!(prev_record:, next_record:)
|
|
60
|
-
# Mode B: call the user block with a Move
|
|
61
|
-
# Mode C: no-op
|
|
62
|
-
def reposition!(record:, column:, prev_record:, next_record:, index:)
|
|
63
|
-
case @mode
|
|
64
|
-
when :delegate
|
|
65
|
-
record.reposition!(prev_record:, next_record:)
|
|
66
|
-
when :block
|
|
67
|
-
@block.call(Move.new(record:, column:, prev: prev_record, next: next_record, index:))
|
|
68
|
-
when :disabled
|
|
69
|
-
nil
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
11
|
+
Config = Plutonium::Positioning::Config
|
|
12
|
+
Move = Plutonium::Positioning::Move
|
|
73
13
|
end
|
|
74
14
|
end
|
|
75
15
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
# This file is deliberately dependency-free — no ActiveSupport, no
|
|
5
|
+
# ActiveRecord, not even the rest of Plutonium::Positioning. A controller,
|
|
6
|
+
# a definition DSL and a view component all need to name a strategy, and
|
|
7
|
+
# none of them should have to drag the model concern in to do it.
|
|
8
|
+
module Positioning
|
|
9
|
+
# Value object passed to Mode B blocks, carrying the full drop context.
|
|
10
|
+
Move = Data.define(:record, :column, :prev, :next, :index)
|
|
11
|
+
|
|
12
|
+
# Strategy configuration object created by the `position_on` DSL.
|
|
13
|
+
#
|
|
14
|
+
# Three modes:
|
|
15
|
+
# Mode A (:delegate) — delegate reposition! to Plutonium::Positioning::Model
|
|
16
|
+
# Mode B (:block) — call a user-supplied block with a Move
|
|
17
|
+
# Mode C (:disabled) — no ordering; relation returned unchanged
|
|
18
|
+
class Config
|
|
19
|
+
# Mode A, default attribute :position
|
|
20
|
+
def self.default
|
|
21
|
+
new(:delegate, :position, nil)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Mode A, custom attribute
|
|
25
|
+
def self.attribute(attr)
|
|
26
|
+
new(:delegate, attr.to_sym, nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Mode B — orders by attr, write delegated to block
|
|
30
|
+
def self.with_block(attr, block)
|
|
31
|
+
new(:block, attr.to_sym, block)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Mode C — disabled
|
|
35
|
+
def self.disabled
|
|
36
|
+
new(:disabled, nil, nil)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :attribute
|
|
40
|
+
|
|
41
|
+
def initialize(mode, attribute, block)
|
|
42
|
+
@mode = mode
|
|
43
|
+
@attribute = attribute
|
|
44
|
+
@block = block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def disabled?
|
|
48
|
+
@mode == :disabled
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Mode A — the framework owns the write, via Positioning::Model. Callers
|
|
52
|
+
# ask because Mode A is the only mode whose storage they may reason about:
|
|
53
|
+
# a fractional position between two neighbours, in a known scope group. A
|
|
54
|
+
# Mode B block owns both the storage and its own notion of neighbours, so
|
|
55
|
+
# nothing outside it may second-guess the drop it is handed.
|
|
56
|
+
def delegate?
|
|
57
|
+
@mode == :delegate
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Apply positional ordering to a relation.
|
|
61
|
+
# Mode A/B: relation.reorder(attribute)
|
|
62
|
+
# Mode C: return relation unchanged
|
|
63
|
+
def order(relation)
|
|
64
|
+
return relation if disabled?
|
|
65
|
+
relation.reorder(@attribute)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Persist the new position for a dropped record. Returns true when the
|
|
69
|
+
# caller must reconcile its view of the list because positions other than
|
|
70
|
+
# this record's may have changed.
|
|
71
|
+
#
|
|
72
|
+
# Mode A: delegate to record.reposition!(prev_record:, next_record:), and
|
|
73
|
+
# report whether it had to rebalance the scope group.
|
|
74
|
+
# Mode B: call the user block with a Move; always true — the block is an
|
|
75
|
+
# opaque write, and gems in this space routinely renumber the whole
|
|
76
|
+
# group (acts_as_list does).
|
|
77
|
+
# Mode C: no-op; always false — nothing was written.
|
|
78
|
+
#
|
|
79
|
+
# `column` is the kanban column key on a board, and nil on every other
|
|
80
|
+
# surface (index tables, nested tables, grids) — those have no columns.
|
|
81
|
+
def reposition!(record:, prev_record:, next_record:, index:, column: nil)
|
|
82
|
+
case @mode
|
|
83
|
+
when :delegate
|
|
84
|
+
record.reposition!(prev_record:, next_record:).rebalanced?
|
|
85
|
+
when :block
|
|
86
|
+
@block.call(Move.new(record:, column:, prev: prev_record, next: next_record, index:))
|
|
87
|
+
true
|
|
88
|
+
when :disabled
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Positioning
|
|
5
|
+
# Standalone decimal/fractional ordering. Kanban-independent.
|
|
6
|
+
#
|
|
7
|
+
# Including this concern and calling `positioned_on` gives a model:
|
|
8
|
+
# - automatic position assignment on create (appends to the end of its scope group)
|
|
9
|
+
# - `reposition!(prev_record:, next_record:)` for drag-and-drop reordering
|
|
10
|
+
# - `backfill_positions!` class method to number existing rows
|
|
11
|
+
#
|
|
12
|
+
# This lives one level below Plutonium::Positioning on purpose. A concern is
|
|
13
|
+
# mixed into user models, and every constant nested in the included module
|
|
14
|
+
# joins that model's constant lookup — so a concern named Positioning would
|
|
15
|
+
# make a bare `Config` inside the model resolve to
|
|
16
|
+
# Plutonium::Positioning::Config, silently shadowing the app's own ::Config.
|
|
17
|
+
# Keeping the namespace and the mixin separate stops the leak.
|
|
18
|
+
module Model
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
class_attribute :positioning_column, instance_accessor: false, default: :position
|
|
23
|
+
class_attribute :positioning_scope_attr, instance_accessor: false, default: nil
|
|
24
|
+
# Separate from positioning_column, which has a default and so cannot
|
|
25
|
+
# answer "did this model actually call positioned_on?". Including the
|
|
26
|
+
# concern without calling it skips `before_create :assign_initial_position`
|
|
27
|
+
# entirely — every row keeps a nil position and the order is arbitrary —
|
|
28
|
+
# so the definition DSL checks this flag, not mere inclusion.
|
|
29
|
+
class_attribute :positioning_declared, instance_accessor: false, default: false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
# Opt in to positional ordering.
|
|
34
|
+
#
|
|
35
|
+
# positioned_on :position, scope: :status
|
|
36
|
+
#
|
|
37
|
+
# @param column [Symbol] the decimal column that stores positions
|
|
38
|
+
# @param scope [Symbol, nil] group rows by this column; nil = single global group
|
|
39
|
+
def positioned_on(column = :position, scope: nil)
|
|
40
|
+
self.positioning_column = column
|
|
41
|
+
self.positioning_scope_attr = scope
|
|
42
|
+
self.positioning_declared = true
|
|
43
|
+
before_create :assign_initial_position
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Number every row in the table per scope group as 1.0, 2.0, … in
|
|
47
|
+
# +order+ order. Safe to call on an empty table.
|
|
48
|
+
#
|
|
49
|
+
# @param order [Symbol] column to sort by when assigning positions
|
|
50
|
+
def backfill_positions!(order: :created_at)
|
|
51
|
+
groups = positioning_scope_attr ? all.group_by(&positioning_scope_attr) : {nil => all.to_a}
|
|
52
|
+
groups.each_value do |rows|
|
|
53
|
+
# `transaction` on THIS class, never ActiveRecord::Base.transaction.
|
|
54
|
+
# A transaction is opened on the receiver's connection pool; the
|
|
55
|
+
# UPDATEs below go out on this model's pool (update_column →
|
|
56
|
+
# self.class._update_record). For a model on a secondary database
|
|
57
|
+
# those are two different connections: ActiveRecord::Base would BEGIN
|
|
58
|
+
# on primary and do nothing else with it while every UPDATE
|
|
59
|
+
# autocommits on the model's own connection — a crash halfway leaves
|
|
60
|
+
# the group partly renumbered, with duplicate/gapped positions and
|
|
61
|
+
# nothing to roll back. Do not "simplify" this back.
|
|
62
|
+
transaction do
|
|
63
|
+
rows.sort_by { |r| r.public_send(order) }.each_with_index do |row, i|
|
|
64
|
+
row.update_column(positioning_column, (i + 1).to_f)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Move this record so it sits between +prev_record+ and +next_record+
|
|
72
|
+
# within its scope group. Pass nil for either neighbor to move to an end.
|
|
73
|
+
#
|
|
74
|
+
# If the gap between the two neighbors is exhausted (too small to split)
|
|
75
|
+
# the scope group is rebalanced first so that fresh integer positions are
|
|
76
|
+
# available, then the record is positioned between the reloaded neighbors.
|
|
77
|
+
#
|
|
78
|
+
# @param prev_record [ActiveRecord::Base, nil]
|
|
79
|
+
# @param next_record [ActiveRecord::Base, nil]
|
|
80
|
+
# @return [Plutonium::Positioning::Result] whose `rebalanced?` tells the
|
|
81
|
+
# caller whether rows OTHER than this one moved — a drag-and-drop client
|
|
82
|
+
# that optimistically reordered its own view needs to re-sync when so.
|
|
83
|
+
def reposition!(prev_record:, next_record:)
|
|
84
|
+
col = self.class.positioning_column
|
|
85
|
+
prev_val = prev_record&.public_send(col)
|
|
86
|
+
next_val = next_record&.public_send(col)
|
|
87
|
+
rebalanced = Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
|
|
88
|
+
if rebalanced
|
|
89
|
+
rebalance_scope_group!
|
|
90
|
+
prev_val = prev_record&.reload&.public_send(col)
|
|
91
|
+
next_val = next_record&.reload&.public_send(col)
|
|
92
|
+
end
|
|
93
|
+
update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
|
|
94
|
+
Plutonium::Positioning::Result.new(rebalanced:)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def assign_initial_position
|
|
100
|
+
col = self.class.positioning_column
|
|
101
|
+
return if public_send(col).present?
|
|
102
|
+
max = positioning_group_relation.maximum(col) || 0.0
|
|
103
|
+
public_send(:"#{col}=", max + 1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def positioning_group_relation
|
|
107
|
+
rel = self.class.all
|
|
108
|
+
attr = self.class.positioning_scope_attr
|
|
109
|
+
attr ? rel.where(attr => public_send(attr)) : rel
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def rebalance_scope_group!
|
|
113
|
+
col = self.class.positioning_column
|
|
114
|
+
# self.class.transaction, never ActiveRecord::Base.transaction — the
|
|
115
|
+
# BEGIN must land on the connection the UPDATEs below actually use. See
|
|
116
|
+
# the note in backfill_positions!: on a secondary-database model the
|
|
117
|
+
# ActiveRecord::Base form opens an empty transaction on primary and every
|
|
118
|
+
# renumbering UPDATE autocommits unprotected, so a failure mid-rebalance
|
|
119
|
+
# is unrecoverable. Rebalancing is exactly where that matters.
|
|
120
|
+
self.class.transaction do
|
|
121
|
+
positioning_group_relation.order(col).each_with_index do |row, i|
|
|
122
|
+
row.update_column(col, (i + 1).to_f)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -1,22 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# require_relative, not require: test/positioning_postgres_check.rb loads this
|
|
4
|
+
# file directly with require_relative and no $LOAD_PATH setup — it is
|
|
5
|
+
# deliberately framework-free, needing only activerecord + pg. A plain `require`
|
|
6
|
+
# resolves against $LOAD_PATH and so fails there. Matches how lib/plutonium.rb
|
|
7
|
+
# pulls in its own files.
|
|
8
|
+
require_relative "positioning/config"
|
|
9
|
+
require_relative "positioning/model"
|
|
10
|
+
|
|
3
11
|
module Plutonium
|
|
4
|
-
#
|
|
12
|
+
# Namespace for decimal/fractional ordering. Kanban-independent.
|
|
5
13
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# - `backfill_positions!` class method to number existing rows
|
|
14
|
+
# This module is a pure namespace — it is never included into a model. The
|
|
15
|
+
# AR mixin is Plutonium::Positioning::Model and the drag strategy object is
|
|
16
|
+
# Plutonium::Positioning::Config.
|
|
10
17
|
#
|
|
11
18
|
# Pure math helpers are exposed as module-level methods so they can be
|
|
12
19
|
# called without an AR instance:
|
|
13
20
|
# Plutonium::Positioning.position_between(1.0, 3.0) # => 2.0
|
|
14
21
|
# Plutonium::Positioning.gap_exhausted?(1.0, 1.0) # => true
|
|
15
22
|
module Positioning
|
|
16
|
-
extend ActiveSupport::Concern
|
|
17
|
-
|
|
18
23
|
EPSILON = 1e-6
|
|
19
24
|
|
|
25
|
+
# Outcome of a Model#reposition! call. `rebalanced?` is true when the gap
|
|
26
|
+
# between the neighbours was exhausted and the whole scope group had to be
|
|
27
|
+
# renumbered — the caller needs to know because every other row's position
|
|
28
|
+
# changed, so any cached client-side view of the list is stale.
|
|
29
|
+
#
|
|
30
|
+
# It lives on the namespace, NOT inside Model, for the same reason the
|
|
31
|
+
# concern was split out of the namespace in the first place: Model is
|
|
32
|
+
# included into user models, so anything nested in it joins their constant
|
|
33
|
+
# lookup and can shadow their own constants (see the comment in model.rb).
|
|
34
|
+
Result = Data.define(:rebalanced) do
|
|
35
|
+
def rebalanced? = rebalanced
|
|
36
|
+
end
|
|
37
|
+
|
|
20
38
|
# Returns the position that sits between +prev_val+ and +next_val+.
|
|
21
39
|
#
|
|
22
40
|
# Rules:
|
|
@@ -38,85 +56,6 @@ module Plutonium
|
|
|
38
56
|
(next_val - prev_val).abs < EPSILON
|
|
39
57
|
end
|
|
40
58
|
|
|
41
|
-
included do
|
|
42
|
-
class_attribute :positioning_column, instance_accessor: false, default: :position
|
|
43
|
-
class_attribute :positioning_scope_attr, instance_accessor: false, default: nil
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
class_methods do
|
|
47
|
-
# Opt in to positional ordering.
|
|
48
|
-
#
|
|
49
|
-
# positioned_on :position, scope: :status
|
|
50
|
-
#
|
|
51
|
-
# @param column [Symbol] the decimal column that stores positions
|
|
52
|
-
# @param scope [Symbol, nil] group rows by this column; nil = single global group
|
|
53
|
-
def positioned_on(column = :position, scope: nil)
|
|
54
|
-
self.positioning_column = column
|
|
55
|
-
self.positioning_scope_attr = scope
|
|
56
|
-
before_create :assign_initial_position
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Number every row in the table per scope group as 1.0, 2.0, … in
|
|
60
|
-
# +order+ order. Safe to call on an empty table.
|
|
61
|
-
#
|
|
62
|
-
# @param order [Symbol] column to sort by when assigning positions
|
|
63
|
-
def backfill_positions!(order: :created_at)
|
|
64
|
-
groups = positioning_scope_attr ? all.group_by(&positioning_scope_attr) : {nil => all.to_a}
|
|
65
|
-
groups.each_value do |rows|
|
|
66
|
-
ActiveRecord::Base.transaction do
|
|
67
|
-
rows.sort_by { |r| r.public_send(order) }.each_with_index do |row, i|
|
|
68
|
-
row.update_column(positioning_column, (i + 1).to_f)
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Move this record so it sits between +prev_record+ and +next_record+
|
|
76
|
-
# within its scope group. Pass nil for either neighbor to move to an end.
|
|
77
|
-
#
|
|
78
|
-
# If the gap between the two neighbors is exhausted (too small to split)
|
|
79
|
-
# the scope group is rebalanced first so that fresh integer positions are
|
|
80
|
-
# available, then the record is positioned between the reloaded neighbors.
|
|
81
|
-
#
|
|
82
|
-
# @param prev_record [ActiveRecord::Base, nil]
|
|
83
|
-
# @param next_record [ActiveRecord::Base, nil]
|
|
84
|
-
def reposition!(prev_record:, next_record:)
|
|
85
|
-
col = self.class.positioning_column
|
|
86
|
-
prev_val = prev_record&.public_send(col)
|
|
87
|
-
next_val = next_record&.public_send(col)
|
|
88
|
-
if Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
|
|
89
|
-
rebalance_scope_group!
|
|
90
|
-
prev_val = prev_record&.reload&.public_send(col)
|
|
91
|
-
next_val = next_record&.reload&.public_send(col)
|
|
92
|
-
end
|
|
93
|
-
update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
private
|
|
97
|
-
|
|
98
|
-
def assign_initial_position
|
|
99
|
-
col = self.class.positioning_column
|
|
100
|
-
return if public_send(col).present?
|
|
101
|
-
max = positioning_group_relation.maximum(col) || 0.0
|
|
102
|
-
public_send(:"#{col}=", max + 1)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def positioning_group_relation
|
|
106
|
-
rel = self.class.all
|
|
107
|
-
attr = self.class.positioning_scope_attr
|
|
108
|
-
attr ? rel.where(attr => public_send(attr)) : rel
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def rebalance_scope_group!
|
|
112
|
-
col = self.class.positioning_column
|
|
113
|
-
ActiveRecord::Base.transaction do
|
|
114
|
-
positioning_group_relation.order(col).each_with_index do |row, i|
|
|
115
|
-
row.update_column(col, (i + 1).to_f)
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
59
|
# Migration helper that adds a position column pre-tuned for fractional
|
|
121
60
|
# ordering. Mixed into ActiveRecord's table-definition classes by the
|
|
122
61
|
# railtie, so it is available in both create_table and change_table:
|
data/lib/plutonium/railtie.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Plutonium
|
|
|
41
41
|
|
|
42
42
|
initializer "plutonium.register_migrations" do
|
|
43
43
|
Plutonium::Migrations.register(:wizards, Plutonium.root.join("db/migrate/wizard").to_s)
|
|
44
|
+
Plutonium::Migrations.register(:async_interactions, Plutonium.root.join("db/migrate/async_interactions").to_s)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
# Runs after the host's config/initializers/plutonium.rb (load_config_initializers)
|