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
|
@@ -15,7 +15,7 @@ module Plutonium
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def page_actions
|
|
18
|
-
super || current_definition.defined_actions.values.select { |a| a.record_action? && !a.
|
|
18
|
+
super || current_definition.defined_actions.values.select { |a| a.record_action? && !a.hidden? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def render_default_content
|
|
@@ -25,28 +25,45 @@ module Plutonium
|
|
|
25
25
|
# modal: a slideover/centered dialog has no room for a side rail.
|
|
26
26
|
return render_modal_details if in_modal?
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
# Width comes from configuration (`page_width` / `display_width` on
|
|
29
|
+
# the definition). A modal is excluded above — the dialog sizes
|
|
30
|
+
# itself — and `:full` resolves to no classes, leaving the markup
|
|
31
|
+
# exactly as it was before widths were configurable.
|
|
32
|
+
constrain_width do
|
|
33
|
+
if aside_present?
|
|
34
|
+
div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_240px] gap-6") do
|
|
35
|
+
div { render partial("resource_details") }
|
|
36
|
+
aside(class: "hidden lg:block") { render_aside }
|
|
37
|
+
end
|
|
38
|
+
else
|
|
39
|
+
render partial("resource_details")
|
|
32
40
|
end
|
|
33
|
-
else
|
|
34
|
-
render partial("resource_details")
|
|
35
41
|
end
|
|
36
42
|
end
|
|
37
43
|
|
|
44
|
+
# Wraps the content in the configured width, or yields bare when the
|
|
45
|
+
# resolved width is `:full` — so an unconstrained page gains no
|
|
46
|
+
# pointless wrapper div.
|
|
47
|
+
def constrain_width(&block)
|
|
48
|
+
classes = current_definition.display_width_classes
|
|
49
|
+
return yield if classes.nil?
|
|
50
|
+
|
|
51
|
+
div(class: classes, &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
38
54
|
# The show page is ALWAYS a centered dialog when shown in a modal —
|
|
39
55
|
# deliberately not the definition's modal_mode (which styles :new/:edit
|
|
40
56
|
# as a slideover by default). A centered dialog reads as a focused
|
|
41
57
|
# "detail card" and leaves a launching board/table visible around it.
|
|
42
|
-
# `open_full_url`
|
|
43
|
-
#
|
|
58
|
+
# `open_full_url` lets the user pop the record out to its full page —
|
|
59
|
+
# notably dropping `kanban_modal`, so a card expanded out of a board
|
|
60
|
+
# regains the metadata rail that the modal hides.
|
|
44
61
|
def render_modal_details
|
|
45
62
|
render Plutonium::UI::Modal::Centered.new(
|
|
46
63
|
title: page_title,
|
|
47
64
|
description: page_description,
|
|
48
65
|
size: :lg,
|
|
49
|
-
open_full_url:
|
|
66
|
+
open_full_url: open_full_page_url
|
|
50
67
|
) do
|
|
51
68
|
# The modal body owns no padding — content provides its own (the
|
|
52
69
|
# form uses this same padded, scrollable region). Without it the
|
|
@@ -26,7 +26,7 @@ module Plutonium
|
|
|
26
26
|
|
|
27
27
|
def view_template(&)
|
|
28
28
|
DynaFrameContent() do
|
|
29
|
-
article(class: "pu-wizard
|
|
29
|
+
article(class: tokens("pu-wizard", wizard_width_classes), data: {controller: "wizard"}) do
|
|
30
30
|
render_header
|
|
31
31
|
render_stepper
|
|
32
32
|
render_body
|
|
@@ -43,6 +43,14 @@ module Plutonium
|
|
|
43
43
|
|
|
44
44
|
# Centered wizard header: the title and the wizard-level description
|
|
45
45
|
# (`presents description:`). The per-step heading lives on the step card.
|
|
46
|
+
# Wizard step width. The wizard class resolves it: its own `width`,
|
|
47
|
+
# else `config.wizards.width`. Deliberately does NOT fall through to
|
|
48
|
+
# `config.default_page_width` — wizard widths are configured on their
|
|
49
|
+
# own axis, so moving resource pages leaves wizards where they are.
|
|
50
|
+
def wizard_width_classes
|
|
51
|
+
Plutonium::UI::PageWidth.classes_for(@runner.wizard.class.width)
|
|
52
|
+
end
|
|
53
|
+
|
|
46
54
|
def render_header
|
|
47
55
|
div(class: "pu-wizard-header mb-7 text-center") do
|
|
48
56
|
h1(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") do
|
|
@@ -128,6 +136,7 @@ module Plutonium
|
|
|
128
136
|
render_step_header(step)
|
|
129
137
|
render Plutonium::UI::Form::Wizard.new(
|
|
130
138
|
step:,
|
|
139
|
+
wizard: @runner.wizard,
|
|
131
140
|
# Decorate so attachment fields read as resolved attachments (the
|
|
132
141
|
# Uppy preview rehydrates on Back/resume); other fields pass through.
|
|
133
142
|
data: Plutonium::Wizard::AttachmentData.wrap(@runner.wizard.data[step.key], step),
|
|
@@ -5,10 +5,10 @@ module Plutonium
|
|
|
5
5
|
module Page
|
|
6
6
|
# The "resume or start new" chooser (§4.5), shown at the bare launch URL when
|
|
7
7
|
# a tokened wizard opts in with `on_relaunch :prompt` and the user already has
|
|
8
|
-
# pending (in-progress) runs. Lists each pending run with a Resume link and
|
|
9
|
-
# offers a Start-new button. Keyed/one-time/anchored
|
|
10
|
-
# (they auto-resume their single keyed run); only
|
|
11
|
-
# several concurrent runs to choose between.
|
|
8
|
+
# pending (in-progress) runs. Lists each pending run with a Resume link and a
|
|
9
|
+
# Cancel button, and offers a Start-new button. Keyed/one-time/anchored
|
|
10
|
+
# wizards never reach here (they auto-resume their single keyed run); only
|
|
11
|
+
# tokened wizards can have several concurrent runs to choose between.
|
|
12
12
|
class WizardChooser < Plutonium::UI::Page::Base
|
|
13
13
|
# @param wizard_class [Class] the wizard being launched.
|
|
14
14
|
# @param entries [Array<Plutonium::Wizard::Resume::Entry>] pending runs.
|
|
@@ -57,9 +57,9 @@ module Plutonium
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
# One pending run: its current step + when it was last touched,
|
|
61
|
-
#
|
|
62
|
-
# disabled row rather than a dead link.
|
|
60
|
+
# One pending run: its current step + when it was last touched, a Resume link
|
|
61
|
+
# and a Cancel button. A run whose mount can't be resolved (no `resume_url`)
|
|
62
|
+
# is shown as a disabled row rather than a dead link.
|
|
63
63
|
def render_entry(entry)
|
|
64
64
|
li(class: "flex items-center justify-between gap-4 px-5 py-4", data: {wizard_chooser_entry: entry.current_step}) do
|
|
65
65
|
div(class: "min-w-0") do
|
|
@@ -68,14 +68,39 @@ module Plutonium
|
|
|
68
68
|
end
|
|
69
69
|
p(class: "mt-0.5 text-xs text-[var(--pu-text-muted)]") { "Updated #{updated_ago(entry)} ago" }
|
|
70
70
|
end
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
div(class: "flex items-center gap-2 shrink-0") do
|
|
72
|
+
if entry.resume_url.present?
|
|
73
|
+
a(href: entry.resume_url, class: "pu-btn pu-btn-sm pu-btn-outline", data: {wizard_chooser_resume: true}) { "Resume" }
|
|
74
|
+
else
|
|
75
|
+
span(class: "pu-btn pu-btn-sm pu-btn-outline opacity-50 cursor-not-allowed") { "Resume" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
render_cancel_form(entry) if entry.cancel_url.present?
|
|
75
79
|
end
|
|
76
80
|
end
|
|
77
81
|
end
|
|
78
82
|
|
|
83
|
+
# Abandoning a run is destructive and irreversible — cancelling runs every
|
|
84
|
+
# step's `on_rollback`, destroys the records it persisted, then deletes the
|
|
85
|
+
# row. So it goes through a DELETE form (never a link) behind a
|
|
86
|
+
# `data-turbo-confirm` prompt. Turbo reads `turbo_confirm`; plain
|
|
87
|
+
# `data-confirm` is Rails UJS and would silently never fire here.
|
|
88
|
+
def render_cancel_form(entry)
|
|
89
|
+
form(action: entry.cancel_url, method: "post", class: "inline-block") do
|
|
90
|
+
input(type: "hidden", name: "_method", value: "delete")
|
|
91
|
+
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
|
|
92
|
+
button(
|
|
93
|
+
type: "submit",
|
|
94
|
+
class: "pu-btn pu-btn-sm pu-btn-soft-danger",
|
|
95
|
+
aria_label: "Cancel this #{@wizard_class.label} draft",
|
|
96
|
+
data: {
|
|
97
|
+
wizard_chooser_cancel: true,
|
|
98
|
+
turbo_confirm: "Discard this in-progress #{@wizard_class.label}? This can't be undone."
|
|
99
|
+
}
|
|
100
|
+
) { "Cancel" }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
79
104
|
def render_start_new
|
|
80
105
|
div(class: "border-t border-[var(--pu-border)] bg-[var(--pu-surface-alt)] px-5 py-4") do
|
|
81
106
|
a(href: @start_new_url, class: "pu-btn pu-btn-md pu-btn-primary w-full justify-center", data: {wizard_chooser_start_new: true}) do
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
# The width vocabulary for detail-style pages — the show page, resource
|
|
6
|
+
# forms and wizard steps.
|
|
7
|
+
#
|
|
8
|
+
# These pages are read and filled top-to-bottom in a single column. Inputs
|
|
9
|
+
# and values stretch to their container, so at full content width on a wide
|
|
10
|
+
# screen you get ~1200px-long lines and text boxes: past a comfortable
|
|
11
|
+
# measure, and a long eye-travel between a label and the value beside it.
|
|
12
|
+
# Index and table pages are deliberately NOT covered — a table wants every
|
|
13
|
+
# pixel it can get.
|
|
14
|
+
#
|
|
15
|
+
# The tokens deliberately mirror {Plutonium::UI::Modal::Base::VALID_SIZES}
|
|
16
|
+
# so the framework has one width language rather than two.
|
|
17
|
+
module PageWidth
|
|
18
|
+
# Token => the max-width utility it resolves to. `:full` is the opt-out:
|
|
19
|
+
# no constraint at all, the historical behaviour.
|
|
20
|
+
SIZES = {
|
|
21
|
+
sm: "max-w-2xl",
|
|
22
|
+
md: "max-w-4xl",
|
|
23
|
+
lg: "max-w-6xl",
|
|
24
|
+
xl: "max-w-7xl",
|
|
25
|
+
full: nil
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
VALID_SIZES = SIZES.keys.freeze
|
|
29
|
+
|
|
30
|
+
# The classes for a token: a max-width plus the centring that makes it
|
|
31
|
+
# read as a column rather than a left-hugging block. `:full` contributes
|
|
32
|
+
# nothing, so an unconstrained page keeps exactly the markup it had
|
|
33
|
+
# before this vocabulary existed.
|
|
34
|
+
#
|
|
35
|
+
# @param size [Symbol]
|
|
36
|
+
# @return [String, nil]
|
|
37
|
+
def self.classes_for(size)
|
|
38
|
+
max_width = SIZES.fetch(size) { raise_unknown(size) }
|
|
39
|
+
return nil if max_width.nil?
|
|
40
|
+
|
|
41
|
+
"#{max_width} mx-auto"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Raises on an unknown token rather than silently falling back, matching
|
|
45
|
+
# `modal` and `show_in`. A typo'd width is a bug, not a default.
|
|
46
|
+
def self.validate!(size)
|
|
47
|
+
return size if VALID_SIZES.include?(size)
|
|
48
|
+
raise_unknown(size)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.raise_unknown(size)
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"page width must be one of #{VALID_SIZES.inspect}, got #{size.inspect}"
|
|
54
|
+
end
|
|
55
|
+
private_class_method :raise_unknown
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -12,7 +12,40 @@ module Plutonium
|
|
|
12
12
|
# button). Rows without such a target become a no-op — no
|
|
13
13
|
# special-casing needed in this layer.
|
|
14
14
|
def table_body_row_attributes(wrapped_object)
|
|
15
|
-
super.merge(data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"})
|
|
15
|
+
attributes = super.merge(data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"})
|
|
16
|
+
return attributes unless positioned?
|
|
17
|
+
|
|
18
|
+
# The row — never the grip — is the single source of truth for which
|
|
19
|
+
# record a drag is about. See positioned_controller.js.
|
|
20
|
+
row_data = attributes[:data].merge(positioned_row_id: wrapped_object.identifier)
|
|
21
|
+
|
|
22
|
+
# Present only for a scoped resource, where a drop across groups is
|
|
23
|
+
# meaningless and the drag must refuse it (see Positionable).
|
|
24
|
+
group = @options[:positioned_group_resolver]&.call(wrapped_object.unwrapped)
|
|
25
|
+
row_data = row_data.merge(positioned_group: group) unless group.nil?
|
|
26
|
+
|
|
27
|
+
attributes.merge(data: row_data)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Scope the drag controller to the div that wraps <table>: the tightest
|
|
31
|
+
# element that contains every row, and nothing else the drag cares about.
|
|
32
|
+
def table_wrapper_attributes
|
|
33
|
+
return super unless positioned?
|
|
34
|
+
|
|
35
|
+
super.merge(
|
|
36
|
+
data: {
|
|
37
|
+
controller: "positioned",
|
|
38
|
+
positioned_url_template_value: @options[:positioned_url_template]
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reorderable AND currently orderable. Table::Resource passes the
|
|
44
|
+
# template only when the collection is in ascending position order — a
|
|
45
|
+
# drop under any other sort is rejected by the server, so the client must
|
|
46
|
+
# not offer it (the grip still renders, disabled, to say so).
|
|
47
|
+
def positioned?
|
|
48
|
+
@options[:positioned_url_template].present?
|
|
16
49
|
end
|
|
17
50
|
|
|
18
51
|
# Use custom SelectionColumn with Stimulus data attributes
|
|
@@ -59,17 +59,19 @@ module Plutonium
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def render_action_button(action)
|
|
62
|
-
url = route_options_to_url(action.route_options, resource_class)
|
|
62
|
+
url = with_return_to(route_options_to_url(action.route_options, resource_class))
|
|
63
63
|
|
|
64
64
|
link_to(
|
|
65
65
|
url,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
action.link_attributes({
|
|
67
|
+
class: button_classes(action),
|
|
68
|
+
data: {
|
|
69
|
+
bulk_actions_target: "actionButton",
|
|
70
|
+
bulk_action_name: action.name,
|
|
71
|
+
bulk_action_url: url,
|
|
72
|
+
turbo_frame: action.turbo_frame(current_definition)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
73
75
|
) do
|
|
74
76
|
if action.icon
|
|
75
77
|
render action.icon.new(class: "h-4 w-4")
|
|
@@ -78,6 +80,28 @@ module Plutonium
|
|
|
78
80
|
end
|
|
79
81
|
end
|
|
80
82
|
|
|
83
|
+
# Carries the index the user is on into the action, the same way
|
|
84
|
+
# ActionButton does for row and page actions.
|
|
85
|
+
#
|
|
86
|
+
# Without it a bulk action has nowhere to send the user back to. That
|
|
87
|
+
# is invisible for an action that redirects to the record it changed,
|
|
88
|
+
# and very visible for one that DISPATCHES: async interactions honour
|
|
89
|
+
# return_to (see Dispatchable#dispatch_redirect_target), so a user who
|
|
90
|
+
# archived the rows they had selected was landed on a progress page
|
|
91
|
+
# with their list two clicks behind them.
|
|
92
|
+
#
|
|
93
|
+
# current_page_url, not request.original_url, for the reason
|
|
94
|
+
# ActionButton#default_return_to gives: a toolbar re-rendered by a
|
|
95
|
+
# stream-producing POST must return the user to the page they are on,
|
|
96
|
+
# never to that POST-only endpoint.
|
|
97
|
+
def with_return_to(url)
|
|
98
|
+
uri = URI.parse(url)
|
|
99
|
+
params = Rack::Utils.parse_nested_query(uri.query)
|
|
100
|
+
params["return_to"] = current_page_url
|
|
101
|
+
uri.query = params.to_query
|
|
102
|
+
uri.to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
81
105
|
def button_classes(action)
|
|
82
106
|
color_key = (action.color || action.category)&.to_sym || :primary
|
|
83
107
|
color_class = COLOR_CLASSES[color_key] || COLOR_CLASSES[:primary]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Table
|
|
6
|
+
module Components
|
|
7
|
+
# The drag affordance for a reorderable row.
|
|
8
|
+
#
|
|
9
|
+
# ## Why it lives inside the first cell rather than in a column of its own
|
|
10
|
+
#
|
|
11
|
+
# A dedicated grip column would cost horizontal space on EVERY row of
|
|
12
|
+
# every reorderable table, forever, for a gesture used occasionally. So
|
|
13
|
+
# the grip is pulled left into the first cell's padding with a negative
|
|
14
|
+
# margin exactly equal to its own width: it occupies the padding the cell
|
|
15
|
+
# already had, and the cell's content does not shift by a pixel — with or
|
|
16
|
+
# without the grip, hovered or not.
|
|
17
|
+
#
|
|
18
|
+
# (The padding, not a negative offset outside the cell: the body cell is
|
|
19
|
+
# `overflow-hidden`, which clips at the PADDING box. Anything positioned
|
|
20
|
+
# beyond it would simply be invisible.)
|
|
21
|
+
#
|
|
22
|
+
# ## Why the grip is draggable and the <tr> is not
|
|
23
|
+
#
|
|
24
|
+
# See positioned_controller.js — `draggable="true"` kills text selection
|
|
25
|
+
# inside the element, and a draggable row fights row_click_controller.
|
|
26
|
+
# Both would be silent regressions on an ordinary data table.
|
|
27
|
+
#
|
|
28
|
+
# ## The disabled state IS the way out of the disabled state
|
|
29
|
+
#
|
|
30
|
+
# Dropping between two rows only means something when the visual order is
|
|
31
|
+
# the stored order, so the server rejects a drop made under any other sort
|
|
32
|
+
# (including a DESCENDING position sort). Rather than hiding the
|
|
33
|
+
# affordance — which would leave the user with no hint that the table is
|
|
34
|
+
# reorderable at all, and no way to make it so — it renders as a link that
|
|
35
|
+
# applies the ascending position sort. That is precisely why `position_on`
|
|
36
|
+
# registers `sort <attribute>`.
|
|
37
|
+
#
|
|
38
|
+
# ## Why one component serves both the table and the card grid
|
|
39
|
+
#
|
|
40
|
+
# The behaviour — draggable button, keyboard hint, disabled-is-the-way-out
|
|
41
|
+
# link — is identical on both surfaces; only WHERE the grip sits differs.
|
|
42
|
+
# A table row has padding to hide it in, a card does not, so the placement
|
|
43
|
+
# is the one thing `variant:` switches (see Table::Theme). Two components
|
|
44
|
+
# would mean two chances for the drag contract to drift.
|
|
45
|
+
class DragHandle < Phlexi::Table::HTML
|
|
46
|
+
# Placement only — never behaviour. :row tucks the grip into a table
|
|
47
|
+
# cell's existing left padding; :card floats it over the card's
|
|
48
|
+
# top-left corner, since a card has no spare gutter to hide it in.
|
|
49
|
+
VARIANTS = {
|
|
50
|
+
row: {grip: :drag_handle, disabled: :drag_handle_disabled},
|
|
51
|
+
card: {grip: :drag_handle_card, disabled: :drag_handle_card_disabled}
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# @param sort_url [String, nil] nil when dragging is live; otherwise the
|
|
55
|
+
# URL that puts the collection back into ascending position order.
|
|
56
|
+
# @param variant [Symbol] :row (table cell) or :card (grid card)
|
|
57
|
+
def initialize(sort_url: nil, variant: :row)
|
|
58
|
+
@sort_url = sort_url
|
|
59
|
+
@variant = VARIANTS.fetch(variant)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def view_template
|
|
63
|
+
@sort_url ? render_disabled : render_grip
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def render_grip
|
|
69
|
+
button(
|
|
70
|
+
type: "button",
|
|
71
|
+
draggable: "true",
|
|
72
|
+
class: themed(@variant[:grip]),
|
|
73
|
+
title: "Drag to reorder",
|
|
74
|
+
aria: {label: "Drag to reorder. Use the up and down arrow keys to move this row."},
|
|
75
|
+
data: {positioned_grip: ""}
|
|
76
|
+
) { icon }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_disabled
|
|
80
|
+
a(
|
|
81
|
+
href: @sort_url,
|
|
82
|
+
class: themed(@variant[:disabled]),
|
|
83
|
+
title: "Sort by position to reorder"
|
|
84
|
+
) { icon }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def icon
|
|
88
|
+
render Phlex::TablerIcons::GripVertical.new(class: "w-4 h-4")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A first-column cell with a grip in front of it.
|
|
92
|
+
#
|
|
93
|
+
# The table renders whatever the column's own block returned; this
|
|
94
|
+
# wraps that component rather than replacing it, so a formatter, a
|
|
95
|
+
# custom block or an inferred display component all keep rendering
|
|
96
|
+
# exactly as they did. Flex rather than inline flow because that inner
|
|
97
|
+
# component may be block or inline depending on the field type, and the
|
|
98
|
+
# grip must sit beside it either way.
|
|
99
|
+
#
|
|
100
|
+
# Nested inside DragHandle so referencing it loads this file — a
|
|
101
|
+
# sibling top-level class in drag_handle.rb would be invisible to
|
|
102
|
+
# Zeitwerk until something happened to touch DragHandle first.
|
|
103
|
+
class Cell < Phlexi::Table::HTML
|
|
104
|
+
def initialize(cell, sort_url: nil)
|
|
105
|
+
@cell = cell
|
|
106
|
+
@sort_url = sort_url
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def view_template
|
|
110
|
+
div(class: themed(:drag_handle_cell)) do
|
|
111
|
+
render DragHandle.new(sort_url: @sort_url)
|
|
112
|
+
div(class: "min-w-0") { render @cell }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -142,10 +142,7 @@ module Plutonium
|
|
|
142
142
|
tag = input_options[:as] || field_options[:as]
|
|
143
143
|
tag_attributes = input_options.except(:wrapper, :as)
|
|
144
144
|
|
|
145
|
-
tag_block = input_definition[:block] || ->(f) {
|
|
146
|
-
tag ||= f.inferred_field_component
|
|
147
|
-
f.send(:"#{tag}_tag", **tag_attributes, class: "w-full")
|
|
148
|
-
}
|
|
145
|
+
tag_block = input_definition[:block] || ->(f) { f.component_for(tag, **tag_attributes, class: "w-full") }
|
|
149
146
|
|
|
150
147
|
field_options = field_options.except(:as)
|
|
151
148
|
|
|
@@ -34,7 +34,7 @@ module Plutonium
|
|
|
34
34
|
|
|
35
35
|
def render_dropdown_menu
|
|
36
36
|
div(
|
|
37
|
-
class: "hidden absolute right-0 z-50 mt-1 w-40 origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden",
|
|
37
|
+
class: "hidden absolute right-0 z-50 mt-1 w-max min-w-40 max-w-[min(20rem,90vw)] origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden",
|
|
38
38
|
style: "box-shadow: var(--pu-shadow-lg)",
|
|
39
39
|
data: {resource_drop_down_target: "menu"}
|
|
40
40
|
) do
|
|
@@ -4,6 +4,8 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Table
|
|
6
6
|
class Resource < Plutonium::UI::Component::Base
|
|
7
|
+
include Plutonium::UI::Component::Positionable
|
|
8
|
+
|
|
7
9
|
attr_reader :collection, :resource_fields, :resource_definition
|
|
8
10
|
|
|
9
11
|
def initialize(collection, resource_fields:, resource_definition:)
|
|
@@ -73,7 +75,7 @@ module Plutonium
|
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
def current_search_url
|
|
76
|
-
|
|
78
|
+
current_query_object.request_path
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
def render_bulk_actions_toolbar
|
|
@@ -92,7 +94,9 @@ module Plutonium
|
|
|
92
94
|
end
|
|
93
95
|
|
|
94
96
|
def render_table
|
|
95
|
-
|
|
97
|
+
table_options = position_drag_enabled? ? {positioned_url_template: position_url_template, positioned_group_resolver: position_group_resolver} : {}
|
|
98
|
+
|
|
99
|
+
render Plutonium::UI::Table::Base.new(collection, **table_options) do |table|
|
|
96
100
|
# Selection column only renders when bulk actions exist —
|
|
97
101
|
# the server already knows, so no JS toggle is needed.
|
|
98
102
|
# Use :_selection as column key to avoid conflicts with field columns;
|
|
@@ -103,6 +107,11 @@ module Plutonium
|
|
|
103
107
|
policy_resolver: ->(record) { policy_for(record:) }
|
|
104
108
|
end
|
|
105
109
|
|
|
110
|
+
# The grip rides in the FIRST column that actually renders — tracked
|
|
111
|
+
# rather than indexed off @resource_fields, because a `condition:`
|
|
112
|
+
# can skip the nominal first column and the grip must not go with it.
|
|
113
|
+
grip_pending = !position_config.nil?
|
|
114
|
+
|
|
106
115
|
@resource_fields.each do |name|
|
|
107
116
|
field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options].dup : {}
|
|
108
117
|
|
|
@@ -125,6 +134,13 @@ module Plutonium
|
|
|
125
134
|
display_tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
|
|
126
135
|
column_tag_attributes = column_options.except(:wrapper, :as, :align, :condition, *field_level_keys)
|
|
127
136
|
tag_attributes = display_tag_attributes.merge(column_tag_attributes)
|
|
137
|
+
|
|
138
|
+
# A `formatter:` produces the cell string itself, so render it
|
|
139
|
+
# through the formatted-value component regardless of the column's
|
|
140
|
+
# type. Otherwise the Proc would be handed to a typed component
|
|
141
|
+
# (boolean pill, currency…) and leak into its HTML attributes.
|
|
142
|
+
tag = :formatted_value if tag_attributes.key?(:formatter)
|
|
143
|
+
|
|
128
144
|
tag_block = if column_definition[:block]
|
|
129
145
|
# User-provided blocks receive the raw record for convenience
|
|
130
146
|
user_block = column_definition[:block]
|
|
@@ -132,11 +148,18 @@ module Plutonium
|
|
|
132
148
|
else
|
|
133
149
|
->(wrapped_object, key) {
|
|
134
150
|
f = wrapped_object.field(key)
|
|
151
|
+
# Infer ONCE per column (this block runs per row), so every row
|
|
152
|
+
# renders through the same tag even where values differ in type.
|
|
135
153
|
tag ||= f.inferred_field_component
|
|
136
|
-
f.
|
|
154
|
+
f.component_for(tag, **tag_attributes)
|
|
137
155
|
}
|
|
138
156
|
end
|
|
139
157
|
|
|
158
|
+
if grip_pending
|
|
159
|
+
grip_pending = false
|
|
160
|
+
tag_block = wrap_with_drag_handle(tag_block)
|
|
161
|
+
end
|
|
162
|
+
|
|
140
163
|
# For table columns, only extract column-level options (label and align)
|
|
141
164
|
# Field-level options like description and placeholder don't make sense in table cells
|
|
142
165
|
field_options = field_options.except(:condition).merge(**column_options.slice(:align, :label))
|
|
@@ -151,7 +174,7 @@ module Plutonium
|
|
|
151
174
|
policy = policy_for(record:)
|
|
152
175
|
|
|
153
176
|
actions = resource_definition.defined_actions
|
|
154
|
-
.select { |k, a| a.collection_record_action? && !a.
|
|
177
|
+
.select { |k, a| a.collection_record_action? && !a.hidden? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
|
|
155
178
|
.values
|
|
156
179
|
|
|
157
180
|
primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
|
|
@@ -176,9 +199,31 @@ module Plutonium
|
|
|
176
199
|
end
|
|
177
200
|
end
|
|
178
201
|
|
|
202
|
+
# Returns a column block that renders `inner`'s cell with the grip in
|
|
203
|
+
# front of it. Wraps rather than replaces, so a formatter, a custom
|
|
204
|
+
# column block and an inferred component all keep rendering as they did.
|
|
205
|
+
#
|
|
206
|
+
# `sort_url` is resolved ONCE, outside the per-row lambda: it depends on
|
|
207
|
+
# the collection's sort, not on the record, and building it per row would
|
|
208
|
+
# re-derive the same URL for every row of every page.
|
|
209
|
+
#
|
|
210
|
+
# The per-record `reposition?` check, by contrast, HAS to run per row.
|
|
211
|
+
# Rows that fail it render exactly as they did before, unwrapped.
|
|
212
|
+
def wrap_with_drag_handle(inner)
|
|
213
|
+
sort_url = position_grip_sort_url
|
|
214
|
+
|
|
215
|
+
->(wrapped_object, key) {
|
|
216
|
+
record = wrapped_object.unwrapped
|
|
217
|
+
cell = inner.call(wrapped_object, key)
|
|
218
|
+
next cell unless repositionable?(record)
|
|
219
|
+
|
|
220
|
+
Plutonium::UI::Table::Components::DragHandle::Cell.new(cell, sort_url: sort_url)
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
179
224
|
def bulk_actions
|
|
180
225
|
@bulk_actions ||= resource_definition.defined_actions
|
|
181
|
-
.select { |k, a| a.bulk_action? && a.condition_met?(view_context) }
|
|
226
|
+
.select { |k, a| a.bulk_action? && !a.hidden? && a.condition_met?(view_context) }
|
|
182
227
|
.values
|
|
183
228
|
end
|
|
184
229
|
|