plutonium 0.62.2 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/.standard.yml +1 -1
- data/CHANGELOG.md +73 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +323 -141
- data/gemfiles/rails_8.0.gemfile.lock +126 -116
- data/gemfiles/rails_8.1.gemfile.lock +127 -117
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +20 -20
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +101 -22
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Resource
|
|
5
|
+
module Controllers
|
|
6
|
+
# Drag-reorder endpoint for resources whose definition declares
|
|
7
|
+
# `position_on` — index tables, nested association tables, and grids.
|
|
8
|
+
# (The kanban board has its own richer endpoint: KanbanActions#kanban_move.)
|
|
9
|
+
#
|
|
10
|
+
# ## The request
|
|
11
|
+
#
|
|
12
|
+
# POST <member>/reposition?<the collection's own query string>
|
|
13
|
+
# params: prev_id, next_id, to_index
|
|
14
|
+
#
|
|
15
|
+
# prev_id/next_id are the ids of the dropped row's VISIBLE neighbours,
|
|
16
|
+
# either nullable for a drop at an end of the VIEWPORT. A blank id is a
|
|
17
|
+
# claim about the client's page, never about the positioning group: rows
|
|
18
|
+
# hidden by pagination or a filter may still sit beyond it, and Mode A
|
|
19
|
+
# looks the real boundary neighbour up rather than anchoring off nil (see
|
|
20
|
+
# #resolve_position_boundaries — this is what keeps a bottom-of-page drop
|
|
21
|
+
# from writing a duplicate position).
|
|
22
|
+
#
|
|
23
|
+
# The trailing query string is load-bearing, not decoration: it is the
|
|
24
|
+
# index's own query (search / filters / scope / sort / page / view), and it
|
|
25
|
+
# is what lets this action re-render exactly the page the user is looking
|
|
26
|
+
# at — same filters, same page of results — using the ordinary index
|
|
27
|
+
# pipeline rather than a parallel one. Task 7's client must therefore post
|
|
28
|
+
# to the member reposition path with `window.location.search` appended.
|
|
29
|
+
#
|
|
30
|
+
# ## The response
|
|
31
|
+
#
|
|
32
|
+
# The client moves the row optimistically, so a clean drop answers 204 and
|
|
33
|
+
# nothing repaints. The collection is streamed back only when the client's
|
|
34
|
+
# view is or may be stale:
|
|
35
|
+
#
|
|
36
|
+
# clean Mode A drop, both neighbours resolved → 204 No Content
|
|
37
|
+
# reposition! rebalanced the group → 200 + stream
|
|
38
|
+
# a neighbour did not resolve (drift) → 200 + stream
|
|
39
|
+
# a neighbour belongs to another positioning
|
|
40
|
+
# group → 200 + stream + toast
|
|
41
|
+
# Mode B (opaque block write) → 200 + stream
|
|
42
|
+
# denied reposition? → 403 + stream + toast
|
|
43
|
+
# denied index? → 403, no body
|
|
44
|
+
# dropped under a foreign sort → 422 + stream + toast
|
|
45
|
+
# validation failure / record gone → 422 + stream + toast
|
|
46
|
+
# no position_on, Mode C, or a kanban view → 404
|
|
47
|
+
#
|
|
48
|
+
# ## DOM contract (Tasks 7 & 8 depend on these)
|
|
49
|
+
#
|
|
50
|
+
# #pu-collection-<plural> the wrapper around the rendered table/grid
|
|
51
|
+
# (Plutonium::UI::Page::Index.collection_dom_id)
|
|
52
|
+
# — the stream target, and the element the drag
|
|
53
|
+
# controller scopes itself to.
|
|
54
|
+
# #pu-flash the page's toast region (Plutonium::FLASH_REGION),
|
|
55
|
+
# deliberately OUTSIDE the collection wrapper so
|
|
56
|
+
# replacing the collection cannot destroy it.
|
|
57
|
+
module PositionActions
|
|
58
|
+
extend ActiveSupport::Concern
|
|
59
|
+
|
|
60
|
+
# POST <member>/reposition
|
|
61
|
+
def reposition
|
|
62
|
+
config = current_definition.defined_position_config
|
|
63
|
+
|
|
64
|
+
# Not a reorderable surface — a 404, not an authorization failure.
|
|
65
|
+
# Either the resource declares no ordering at all (or Mode C), or the
|
|
66
|
+
# client is on the kanban board, which owns a richer endpoint of its
|
|
67
|
+
# own (kanban_move) and renders no collection wrapper for the
|
|
68
|
+
# reconciliation to target. Checked BEFORE the record is loaded (no
|
|
69
|
+
# point querying for a route that doesn't apply), which means neither
|
|
70
|
+
# verifier has been satisfied by the time we bail — so satisfy both.
|
|
71
|
+
if config.nil? || config.disabled? || position_collection_view == :kanban
|
|
72
|
+
skip_verify_authorize_current!
|
|
73
|
+
skip_verify_current_authorized_scope!
|
|
74
|
+
head :not_found
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# You must be able to SEE a list to reorder it. Without this, a policy
|
|
79
|
+
# with `index? == false, update? == true` would be handed the whole
|
|
80
|
+
# listing by the reconciliation render below. Checked before the
|
|
81
|
+
# record is loaded so the refusal never touches the collection.
|
|
82
|
+
authorize_current! resource_class, to: :index?
|
|
83
|
+
|
|
84
|
+
record = current_authorized_scope.find(params[:id])
|
|
85
|
+
authorize_current! record, to: :reposition?
|
|
86
|
+
|
|
87
|
+
# Dropping "between the two rows either side of me" only means
|
|
88
|
+
# something when the visual order IS the stored order. Task 7's client
|
|
89
|
+
# doesn't offer the drag under a foreign sort; this is the server-side
|
|
90
|
+
# authority behind that, and it rejects BEFORE any write rather than
|
|
91
|
+
# writing a position derived from neighbours that never implied one.
|
|
92
|
+
if config.delegate? && !current_query_object.sorted_ascending_only_by?(config.attribute)
|
|
93
|
+
return render_position_reconciliation(
|
|
94
|
+
reason: "Reordering is only available while the list is sorted by #{config.attribute.to_s.humanize.downcase}."
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Resolve neighbours WITHIN the authorized scope. A nil here would mean
|
|
99
|
+
# "drop at the end", so an id that does not resolve must be treated as
|
|
100
|
+
# drift and reconciled — never silently coerced to nil.
|
|
101
|
+
prev_record, prev_ok, prev_drift = resolve_position_neighbour(params[:prev_id], record:, config:)
|
|
102
|
+
next_record, next_ok, next_drift = resolve_position_neighbour(params[:next_id], record:, config:)
|
|
103
|
+
foreign_group = [prev_drift, next_drift].include?(:foreign_group)
|
|
104
|
+
prev_record, next_record = resolve_position_boundaries(record, config, prev_record, next_record)
|
|
105
|
+
|
|
106
|
+
# Nothing left to anchor to. In Mode A that is never a real drag — a
|
|
107
|
+
# list you can drag in has a row on one side of the drop, and the
|
|
108
|
+
# boundary lookup above supplies the other. It means the client's view
|
|
109
|
+
# is stale (both neighbours drifted), and writing anyway would send the
|
|
110
|
+
# record to position 0.0 at the head of its group on the strength of a
|
|
111
|
+
# request we've just decided not to trust. Reconcile instead: the row
|
|
112
|
+
# snaps back to where it actually is.
|
|
113
|
+
if config.delegate? && prev_record.nil? && next_record.nil?
|
|
114
|
+
# A cross-group drop lands here with BOTH neighbours rejected, and is
|
|
115
|
+
# the one kind of drift worth explaining: it is a standing property
|
|
116
|
+
# of a list that spans groups, not transient staleness. Status stays
|
|
117
|
+
# :ok — the reconciliation is unchanged, this only adds the toast.
|
|
118
|
+
return render_position_reconciliation(
|
|
119
|
+
reason: foreign_group ? position_foreign_group_reason(record) : nil,
|
|
120
|
+
status: :ok
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Bind the return rather than branching on the call directly:
|
|
125
|
+
# `if config.reposition!(...)` reads as a success check, which this is
|
|
126
|
+
# NOT — failure raises. The boolean means "positions other than this
|
|
127
|
+
# record's may have changed, so the client's optimistic view is stale".
|
|
128
|
+
must_reconcile = config.reposition!(
|
|
129
|
+
record:,
|
|
130
|
+
prev_record:,
|
|
131
|
+
next_record:,
|
|
132
|
+
# Floor-clamped, mirroring the kanban drop (KanbanActions#kanban_move
|
|
133
|
+
# clamps to [0, size]): a negative index reaching a Mode B block
|
|
134
|
+
# would index its array from the END, silently anchoring the drop to
|
|
135
|
+
# the wrong row. There is no card list here to give it a ceiling, so
|
|
136
|
+
# the contract Move#index carries is "0 or greater".
|
|
137
|
+
index: [params[:to_index].to_i, 0].max
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# No reason here, even when a neighbour was out of group: reposition!
|
|
141
|
+
# has already run, so the record DID move — the surviving anchor
|
|
142
|
+
# carried the drop and resolve_position_boundaries supplied the other.
|
|
143
|
+
# Telling the user reordering "only works within the same group" on a
|
|
144
|
+
# move that just worked contradicts what they can see. The refusal
|
|
145
|
+
# above is the only place that claim is true.
|
|
146
|
+
if must_reconcile || !prev_ok || !next_ok
|
|
147
|
+
render_position_reconciliation
|
|
148
|
+
else
|
|
149
|
+
head :no_content
|
|
150
|
+
end
|
|
151
|
+
rescue ::ActionPolicy::Unauthorized => e
|
|
152
|
+
# NOTE: the leading :: is REQUIRED — Plutonium::ActionPolicy exists, so
|
|
153
|
+
# a bare ActionPolicy resolves to that namespace and never matches,
|
|
154
|
+
# letting the exception reach the global rescue_from, which re-raises
|
|
155
|
+
# for turbo_stream requests → an HTML error page morphed into the table.
|
|
156
|
+
#
|
|
157
|
+
# authorize_count only bumps after a SUCCESSFUL authorize, so a denial
|
|
158
|
+
# leaves the verifier unsatisfied; we handled authorization by rejecting.
|
|
159
|
+
skip_verify_authorize_current!
|
|
160
|
+
|
|
161
|
+
# A denied index? is not a snap-back: the user may not see this listing
|
|
162
|
+
# at all, so the refusal must not carry it back to them. Only a denied
|
|
163
|
+
# reposition? gets the collection (the row has to return somewhere).
|
|
164
|
+
if e.rule.to_sym == :index?
|
|
165
|
+
skip_verify_current_authorized_scope!
|
|
166
|
+
head :forbidden
|
|
167
|
+
return
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
render_position_reconciliation(
|
|
171
|
+
reason: "You are not authorized to reorder this.",
|
|
172
|
+
status: :forbidden
|
|
173
|
+
)
|
|
174
|
+
rescue ActiveRecord::RecordNotFound
|
|
175
|
+
# The row was destroyed between render and drop. `find` raised before
|
|
176
|
+
# authorize_current!, so satisfy that verifier explicitly.
|
|
177
|
+
skip_verify_authorize_current!
|
|
178
|
+
render_position_reconciliation(reason: "This record no longer exists.")
|
|
179
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
180
|
+
reason = e.record.errors.full_messages.to_sentence.presence ||
|
|
181
|
+
"This record could not be moved."
|
|
182
|
+
render_position_reconciliation(reason:)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
# Returns [neighbour, resolved?]. A blank id is a legitimate end-of-page
|
|
188
|
+
# drop → [nil, true]. Anything the client named that we cannot honour is
|
|
189
|
+
# drift → [nil, false], which forces reconciliation.
|
|
190
|
+
#
|
|
191
|
+
# Two ways to fail. The id may not resolve in the authorized scope at
|
|
192
|
+
# all; or it may resolve to a row in a DIFFERENT positioning group. The
|
|
193
|
+
# second is not exotic — a status-scoped resource lists several groups
|
|
194
|
+
# in one table, with independent and freely interleaved numberings, so
|
|
195
|
+
# anchoring off a neighbour from another group flings the record to an
|
|
196
|
+
# arbitrary point in its own. Group membership is part of "can this
|
|
197
|
+
# neighbour anchor this drop", so it belongs here rather than in a
|
|
198
|
+
# separate check the caller could forget.
|
|
199
|
+
# Returns [neighbour, ok, drift]. `drift` names WHY a neighbour was
|
|
200
|
+
# rejected — :missing (gone, or outside this viewer's scope) versus
|
|
201
|
+
# :foreign_group (real, but in another positioning group). Both reconcile
|
|
202
|
+
# identically, but only the second has something worth telling the user:
|
|
203
|
+
# it is a standing property of the list they are looking at, not a
|
|
204
|
+
# transient staleness that a reload fixes.
|
|
205
|
+
def resolve_position_neighbour(id, record:, config:)
|
|
206
|
+
return [nil, true, nil] if id.blank?
|
|
207
|
+
|
|
208
|
+
neighbour = current_authorized_scope.find_by(id: id)
|
|
209
|
+
return [nil, false, :missing] if neighbour.nil?
|
|
210
|
+
|
|
211
|
+
# Mode B owns its own storage AND its own notion of a group; only the
|
|
212
|
+
# framework's own scope attribute is ours to police.
|
|
213
|
+
group_attr = config.delegate? ? record.class.positioning_scope_attr : nil
|
|
214
|
+
return [nil, false, :foreign_group] if group_attr && neighbour[group_attr] != record[group_attr]
|
|
215
|
+
|
|
216
|
+
[neighbour, true, nil]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# What to tell the user when a drop was refused because its neighbours
|
|
220
|
+
# belong to another positioning group — the case a collection spanning
|
|
221
|
+
# groups (a top-level index of a resource scoped to its parent) invites
|
|
222
|
+
# on nearly every row. Named after the scope attribute so the message
|
|
223
|
+
# says "the same product" rather than restating the column.
|
|
224
|
+
def position_foreign_group_reason(record)
|
|
225
|
+
scope = record.class.positioning_scope_attr.to_s.delete_suffix("_id").humanize.downcase
|
|
226
|
+
"Reordering only works within the same #{scope}."
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Fills in a neighbour the CLIENT could not see.
|
|
230
|
+
#
|
|
231
|
+
# A blank neighbour id means "nothing on that side of my viewport" — it
|
|
232
|
+
# does NOT mean "nothing on that side of the group". Pagination and
|
|
233
|
+
# filters both hide rows, so the last visible row is routinely not the
|
|
234
|
+
# last row. Taken literally, position_between would compute prev + 1 (or
|
|
235
|
+
# next - 1), which on a default-spaced list is EXACTLY the position of
|
|
236
|
+
# the row the client couldn't see: a silent duplicate, reported as a
|
|
237
|
+
# clean drop, that only heals if someone later drags between the tied
|
|
238
|
+
# pair. So we look the real boundary neighbour up instead.
|
|
239
|
+
#
|
|
240
|
+
# The lookup deliberately runs against the model's own group, NOT the
|
|
241
|
+
# authorized scope: a position is a property of the group, and ignoring
|
|
242
|
+
# rows the viewer can't see is the very bug being fixed. Nothing about
|
|
243
|
+
# the hidden row reaches the client — only the dropped record's own
|
|
244
|
+
# resulting position, which had to interleave with it either way.
|
|
245
|
+
#
|
|
246
|
+
# Mode A only: a Mode B block owns its neighbour semantics, and receives
|
|
247
|
+
# the drop exactly as the client described it.
|
|
248
|
+
def resolve_position_boundaries(record, config, prev_record, next_record)
|
|
249
|
+
return [prev_record, next_record] unless config.delegate?
|
|
250
|
+
return [prev_record, next_record] if prev_record && next_record
|
|
251
|
+
|
|
252
|
+
attr = config.attribute
|
|
253
|
+
group = positioning_group_for(record)
|
|
254
|
+
|
|
255
|
+
if next_record.nil? && prev_record
|
|
256
|
+
next_record = group.where(group.table[attr].gt(prev_record[attr])).order(attr => :asc).first
|
|
257
|
+
elsif prev_record.nil? && next_record
|
|
258
|
+
prev_record = group.where(group.table[attr].lt(next_record[attr])).order(attr => :desc).first
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
[prev_record, next_record]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The record's positioning group, minus the record itself (it is being
|
|
265
|
+
# moved, so its current slot must not anchor its new one). Mirrors
|
|
266
|
+
# Positioning::Model#positioning_group_relation, which is private to the
|
|
267
|
+
# model instance.
|
|
268
|
+
def positioning_group_for(record)
|
|
269
|
+
klass = record.class
|
|
270
|
+
relation = klass.all
|
|
271
|
+
group_attr = klass.positioning_scope_attr
|
|
272
|
+
relation = relation.where(group_attr => record[group_attr]) if group_attr
|
|
273
|
+
relation.where.not(klass.primary_key => record.id)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Streams the collection back so the client's optimistic DOM is replaced
|
|
277
|
+
# by the server's truth — the row snaps back on rejection, or settles
|
|
278
|
+
# into its rebalanced order.
|
|
279
|
+
#
|
|
280
|
+
# The status defaults off `reason`: a reconciliation that has something
|
|
281
|
+
# to say is a REJECTION (422 unless the caller is more specific), while a
|
|
282
|
+
# silent one is a successful drop whose result simply differs from what
|
|
283
|
+
# the client drew (a rebalance, drift, a Mode B write).
|
|
284
|
+
#
|
|
285
|
+
# The toast renders the shared _toast partial directly rather than going
|
|
286
|
+
# through `flash`, exactly as the kanban rejection does: this action never
|
|
287
|
+
# renders the layout that would consume a flash, so a stale entry from an
|
|
288
|
+
# earlier request would otherwise leak into the stream.
|
|
289
|
+
def render_position_reconciliation(reason: nil, status: reason ? :unprocessable_content : :ok)
|
|
290
|
+
streams = [
|
|
291
|
+
turbo_stream.update(
|
|
292
|
+
Plutonium::UI::Page::Index.collection_dom_id(resource_class),
|
|
293
|
+
render_position_collection_html
|
|
294
|
+
)
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
if reason
|
|
298
|
+
streams << turbo_stream.append(
|
|
299
|
+
Plutonium::FLASH_REGION,
|
|
300
|
+
partial: "plutonium/toast",
|
|
301
|
+
locals: {type: :warning, msg: reason}
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
render turbo_stream: streams, status:
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Renders the collection component to an HTML-safe string.
|
|
309
|
+
#
|
|
310
|
+
# This is the INDEX's own pipeline, not a copy of it: setup_index_action!
|
|
311
|
+
# applies search / filters / scopes / tenant scoping AND the same
|
|
312
|
+
# pagination, driven by the index query string the client posted with. So
|
|
313
|
+
# the rows streamed back are precisely the rows the user is looking at —
|
|
314
|
+
# page 3 of a filtered list comes back as page 3 of that filtered list.
|
|
315
|
+
#
|
|
316
|
+
# (Kanban's equivalent, kanban_base_relation, deliberately drops
|
|
317
|
+
# pagination because a board pages per column instead. A table has one
|
|
318
|
+
# pager for the whole collection, so reusing setup_index_action! whole is
|
|
319
|
+
# both simpler and more correct here.)
|
|
320
|
+
#
|
|
321
|
+
# The action is passed explicitly as "index": this request's action_name
|
|
322
|
+
# is "reposition", and a resource must not have to define
|
|
323
|
+
# permitted_attributes_for_reposition to be draggable.
|
|
324
|
+
def render_position_collection_html
|
|
325
|
+
setup_index_action!(action: "index")
|
|
326
|
+
|
|
327
|
+
component = (position_collection_view == :grid) ?
|
|
328
|
+
build_grid_collection(action: "index") :
|
|
329
|
+
build_collection(action: "index")
|
|
330
|
+
|
|
331
|
+
view_context.render(component).html_safe
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Which of the resource's index views the client is looking at, resolved
|
|
335
|
+
# exactly as the index page resolves it (?view= → cookie → default).
|
|
336
|
+
def position_collection_view
|
|
337
|
+
@position_collection_view ||= Plutonium::UI::Page::Index.resolve_view(
|
|
338
|
+
current_definition, resource_class, params[:view], cookies
|
|
339
|
+
)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# The collection path this drop belongs to — the index (or nested
|
|
343
|
+
# association) URL the client is displaying, NOT this POST-only member
|
|
344
|
+
# path. Overriding the two Core::Controller seams re-points every URL the
|
|
345
|
+
# re-rendered collection builds — sort headers, the search form, filter
|
|
346
|
+
# pills, pagination, each row action's return_to — at that page. Without
|
|
347
|
+
# it a rebalanced table would come back wired to /tasks/5/reposition,
|
|
348
|
+
# where every one of those links is a 404 waiting to happen.
|
|
349
|
+
#
|
|
350
|
+
# Guarded on the action: every OTHER action on this controller genuinely
|
|
351
|
+
# is the page it renders, and must keep the request-derived defaults.
|
|
352
|
+
def current_page_path
|
|
353
|
+
return super unless action_name == "reposition"
|
|
354
|
+
position_collection_path
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Absolute, like the request.original_url it stands in for — a consumer
|
|
358
|
+
# that got a path here where every other action hands it a full URL
|
|
359
|
+
# would be a trap. request.base_url is this request's own origin, which
|
|
360
|
+
# is the page's origin too: the drop was posted from it.
|
|
361
|
+
def current_page_url
|
|
362
|
+
return super unless action_name == "reposition"
|
|
363
|
+
@position_page_url ||= begin
|
|
364
|
+
query = request.query_string
|
|
365
|
+
"#{request.base_url}#{position_collection_path}#{"?#{query}" if query.present?}"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Pagy carries the request params into every page link. Under this POST
|
|
370
|
+
# that would smuggle prev_id/next_id/to_index into them, so hand Pagy the
|
|
371
|
+
# query string alone — which is the index's own params.
|
|
372
|
+
def pagy_request_context
|
|
373
|
+
return super unless action_name == "reposition"
|
|
374
|
+
super.merge(params: request.GET.to_h)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# The collection path this drop belongs to. `parent:` is what makes a
|
|
378
|
+
# nested association table work: resource_url_for ignores current_parent
|
|
379
|
+
# unless it is passed one, so without it a drop inside
|
|
380
|
+
# /posts/1/nested_comments would resolve to the TOP-LEVEL /comments and
|
|
381
|
+
# send every link in the streamed collection out of the frame, at the
|
|
382
|
+
# wrong collection. current_parent is nil off a nested route, where
|
|
383
|
+
# `parent: nil` is exactly the top-level lookup we want.
|
|
384
|
+
def position_collection_path
|
|
385
|
+
@position_collection_path ||= resource_url_for(resource_class, parent: current_parent)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
@@ -11,16 +11,22 @@ module Plutonium
|
|
|
11
11
|
private
|
|
12
12
|
|
|
13
13
|
def presentable_attributes
|
|
14
|
-
@presentable_attributes ||=
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
@presentable_attributes ||= presentable_attributes_for(action_name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The presentable set for a NAMED action rather than the current one.
|
|
18
|
+
# An action that re-renders a collection on the index's behalf (the
|
|
19
|
+
# reposition drop POST) has no permitted_attributes_for_reposition of
|
|
20
|
+
# its own and must not need one — it asks for the index's set.
|
|
21
|
+
def presentable_attributes_for(action)
|
|
22
|
+
attributes = permitted_attributes_for(action)
|
|
23
|
+
if current_parent && !present_parent?
|
|
24
|
+
attributes -= [parent_input_param, :"#{parent_input_param}_id"]
|
|
25
|
+
end
|
|
26
|
+
if scoped_to_entity? && !present_scoped_entity?
|
|
27
|
+
attributes -= scoped_entity_field_names
|
|
23
28
|
end
|
|
29
|
+
attributes
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def submittable_attributes
|
|
@@ -78,12 +84,12 @@ module Plutonium
|
|
|
78
84
|
@scoped_entity_association = matching_assocs.first&.name
|
|
79
85
|
end
|
|
80
86
|
|
|
81
|
-
def build_collection
|
|
82
|
-
current_definition.collection_class.new(@resource_records, resource_fields:
|
|
87
|
+
def build_collection(action: action_name)
|
|
88
|
+
current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes_for(action), resource_definition: current_definition)
|
|
83
89
|
end
|
|
84
90
|
|
|
85
|
-
def build_grid_collection
|
|
86
|
-
current_definition.grid_class.new(@resource_records, resource_fields:
|
|
91
|
+
def build_grid_collection(action: action_name)
|
|
92
|
+
current_definition.grid_class.new(@resource_records, resource_fields: presentable_attributes_for(action), resource_definition: current_definition)
|
|
87
93
|
end
|
|
88
94
|
|
|
89
95
|
def build_detail
|
|
@@ -10,7 +10,11 @@ module Plutonium
|
|
|
10
10
|
|
|
11
11
|
def current_query_object
|
|
12
12
|
@current_query_object ||=
|
|
13
|
-
|
|
13
|
+
# current_page_path, not request.path: every URL this object builds
|
|
14
|
+
# (sort headers, scope pills, filter links) belongs to the page being
|
|
15
|
+
# rendered, which is not the request path when a stream-producing
|
|
16
|
+
# POST re-renders a collection on the index's behalf.
|
|
17
|
+
Plutonium::Resource::QueryObject.new(resource_class, raw_resource_query_params, current_page_path) do |query_object|
|
|
14
18
|
if current_definition.search_definition
|
|
15
19
|
query_object.define_search proc { |scope, search:|
|
|
16
20
|
current_definition.search_definition.call(scope, search)
|
|
@@ -30,16 +30,19 @@ module Plutonium
|
|
|
30
30
|
launch_wizard_record_action launch_wizard_resource_action
|
|
31
31
|
wizard_record_action commit_wizard_record_action
|
|
32
32
|
wizard_resource_action commit_wizard_resource_action
|
|
33
|
+
cancel_wizard_record_action cancel_wizard_resource_action
|
|
33
34
|
]
|
|
34
35
|
|
|
35
36
|
before_action :authorize_wizard_record_action!, only: %i[
|
|
36
37
|
launch_wizard_record_action
|
|
37
38
|
wizard_record_action commit_wizard_record_action
|
|
39
|
+
cancel_wizard_record_action
|
|
38
40
|
]
|
|
39
41
|
|
|
40
42
|
before_action :authorize_wizard_resource_action!, only: %i[
|
|
41
43
|
launch_wizard_resource_action
|
|
42
44
|
wizard_resource_action commit_wizard_resource_action
|
|
45
|
+
cancel_wizard_resource_action
|
|
43
46
|
]
|
|
44
47
|
end
|
|
45
48
|
|
|
@@ -64,6 +67,11 @@ module Plutonium
|
|
|
64
67
|
wizard_update
|
|
65
68
|
end
|
|
66
69
|
|
|
70
|
+
# DELETE /resources/:id/wizards/:wizard_name/(:token)
|
|
71
|
+
def cancel_wizard_record_action
|
|
72
|
+
wizard_cancel
|
|
73
|
+
end
|
|
74
|
+
|
|
67
75
|
# GET /resources/wizards/:wizard_name/(:token)/:step
|
|
68
76
|
def wizard_resource_action
|
|
69
77
|
skip_verify_current_authorized_scope!
|
|
@@ -76,6 +84,12 @@ module Plutonium
|
|
|
76
84
|
wizard_update
|
|
77
85
|
end
|
|
78
86
|
|
|
87
|
+
# DELETE /resources/wizards/:wizard_name/(:token)
|
|
88
|
+
def cancel_wizard_resource_action
|
|
89
|
+
skip_verify_current_authorized_scope!
|
|
90
|
+
wizard_cancel
|
|
91
|
+
end
|
|
92
|
+
|
|
79
93
|
private
|
|
80
94
|
|
|
81
95
|
# --- surface hooks (override Driving) ---
|
|
@@ -109,6 +123,13 @@ module Plutonium
|
|
|
109
123
|
)
|
|
110
124
|
end
|
|
111
125
|
|
|
126
|
+
def wizard_launch_url
|
|
127
|
+
resource_url_for(
|
|
128
|
+
wizard_url_subject,
|
|
129
|
+
wizard: current_wizard_name
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
112
133
|
# The URL anchor: the scoped record for member (record) actions, the
|
|
113
134
|
# resource class for collection (resource) actions.
|
|
114
135
|
def wizard_url_subject
|
|
@@ -21,6 +21,30 @@ module Plutonium
|
|
|
21
21
|
default_relation_scope(relation)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# `relation_scope` is a class-level macro: it registers a scope handler named
|
|
25
|
+
# `__scoping__active_record_relation__default`, and never defines an instance
|
|
26
|
+
# method called `relation_scope`. So `def relation_scope(relation)` overrides
|
|
27
|
+
# nothing — apply_scope keeps dispatching to the inherited handler, the author's
|
|
28
|
+
# narrowing never runs, and verify_default_relation_scope_applied! is happy
|
|
29
|
+
# because the base handler did call default_relation_scope. Fail-open and silent,
|
|
30
|
+
# so catch it the moment the method is defined rather than at request time.
|
|
31
|
+
def self.method_added(name)
|
|
32
|
+
super
|
|
33
|
+
|
|
34
|
+
return unless name == :relation_scope
|
|
35
|
+
|
|
36
|
+
raise <<~MSG
|
|
37
|
+
#{self} defines `relation_scope` as an instance method, which Plutonium never calls.
|
|
38
|
+
`relation_scope` is a macro, so this scoping silently does nothing.
|
|
39
|
+
|
|
40
|
+
Use the block form instead:
|
|
41
|
+
|
|
42
|
+
relation_scope do |relation|
|
|
43
|
+
default_relation_scope(relation).your_scope
|
|
44
|
+
end
|
|
45
|
+
MSG
|
|
46
|
+
end
|
|
47
|
+
|
|
24
48
|
# Wraps apply_scope to verify default_relation_scope was called.
|
|
25
49
|
# This prevents accidental multi-tenancy leaks when overriding relation_scope.
|
|
26
50
|
def apply_scope(relation, type:, **options)
|
|
@@ -186,6 +210,15 @@ module Plutonium
|
|
|
186
210
|
update?
|
|
187
211
|
end
|
|
188
212
|
|
|
213
|
+
# Authorizes a drag-reorder drop on a table or grid. Delegates to update?
|
|
214
|
+
# by default — override to allow (or forbid) reordering independently of
|
|
215
|
+
# full edit-form access.
|
|
216
|
+
#
|
|
217
|
+
# @return [Boolean] Delegates to update?.
|
|
218
|
+
def reposition?
|
|
219
|
+
update?
|
|
220
|
+
end
|
|
221
|
+
|
|
189
222
|
# Checks if record search is permitted.
|
|
190
223
|
#
|
|
191
224
|
# @return [Boolean] Delegates to index?.
|
|
@@ -2,6 +2,15 @@ module Plutonium
|
|
|
2
2
|
module Resource
|
|
3
3
|
class QueryObject
|
|
4
4
|
attr_reader :search_filter, :search_query
|
|
5
|
+
|
|
6
|
+
# The path every URL this object builds is anchored to — the collection
|
|
7
|
+
# page, which is not necessarily the current request (see
|
|
8
|
+
# Plutonium::Core::Controller#current_page_path). Exposed so the
|
|
9
|
+
# components rendering alongside these URLs — the search form's action,
|
|
10
|
+
# the filter pills' clear links — address the same page instead of each
|
|
11
|
+
# re-deriving it from `request`.
|
|
12
|
+
attr_reader :request_path
|
|
13
|
+
|
|
5
14
|
attr_accessor :default_sort_config, :default_scope_name
|
|
6
15
|
|
|
7
16
|
# Initializes a QueryObject with the given resource_class and parameters.
|
|
@@ -204,6 +213,33 @@ module Plutonium
|
|
|
204
213
|
}
|
|
205
214
|
end
|
|
206
215
|
|
|
216
|
+
# Whether the collection is ordered by `name` ASCENDING and by nothing
|
|
217
|
+
# else — the invariant a drag-reorder depends on. Dropping a row "between
|
|
218
|
+
# the two rows either side of it" only means anything when the visual
|
|
219
|
+
# order IS the stored order: under a title sort the neighbours say nothing
|
|
220
|
+
# about position, and under a DESCENDING position sort they say the
|
|
221
|
+
# opposite of what the write assumes.
|
|
222
|
+
#
|
|
223
|
+
# An explicit selection wins; with none, the resource's default_sort is
|
|
224
|
+
# what the user is looking at (field/direction form only — a `default_sort`
|
|
225
|
+
# block is opaque, so it cannot be claimed to satisfy this).
|
|
226
|
+
#
|
|
227
|
+
# @param name [Symbol, String] the position attribute
|
|
228
|
+
# @return [Boolean]
|
|
229
|
+
def sorted_ascending_only_by?(name)
|
|
230
|
+
fields = selected_sort_fields.map(&:to_s)
|
|
231
|
+
|
|
232
|
+
if fields.any?
|
|
233
|
+
return false unless fields == [name.to_s]
|
|
234
|
+
direction = selected_sort_directions[name.to_sym] || selected_sort_directions[name.to_s] || "ASC"
|
|
235
|
+
return direction.to_s.casecmp("asc").zero?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
return false unless default_sort_config.is_a?(Array)
|
|
239
|
+
field, direction = default_sort_config
|
|
240
|
+
field.to_s == name.to_s && (direction || :asc).to_s.casecmp("asc").zero?
|
|
241
|
+
end
|
|
242
|
+
|
|
207
243
|
private
|
|
208
244
|
|
|
209
245
|
attr_reader :resource_class, :params, :selected_sort_fields, :selected_sort_directions, :selected_scope_filter
|