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
|
@@ -8,6 +8,8 @@ module Plutonium
|
|
|
8
8
|
# panel, scopes pills, bulk actions, footer) so view-switching is
|
|
9
9
|
# purely a render-shape change.
|
|
10
10
|
class Resource < Plutonium::UI::Component::Base
|
|
11
|
+
include Plutonium::UI::Component::Positionable
|
|
12
|
+
|
|
11
13
|
attr_reader :collection, :resource_fields, :resource_definition
|
|
12
14
|
|
|
13
15
|
def initialize(collection, resource_fields:, resource_definition:)
|
|
@@ -42,7 +44,7 @@ module Plutonium
|
|
|
42
44
|
def render_toolbar
|
|
43
45
|
TableToolbar(
|
|
44
46
|
query: current_query_object,
|
|
45
|
-
search_url:
|
|
47
|
+
search_url: current_query_object.request_path,
|
|
46
48
|
search_value: params.dig(:q, :search) || params[:search],
|
|
47
49
|
views: resource_definition.defined_index_views,
|
|
48
50
|
current_view: :grid,
|
|
@@ -71,13 +73,53 @@ module Plutonium
|
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
def render_grid
|
|
74
|
-
div(class: grid_class) do
|
|
76
|
+
div(class: grid_class, data: grid_data) do
|
|
75
77
|
collection.each do |record|
|
|
76
|
-
render Plutonium::UI::Grid::Card.new(
|
|
78
|
+
render Plutonium::UI::Grid::Card.new(
|
|
79
|
+
record,
|
|
80
|
+
resource_definition:,
|
|
81
|
+
resource_fields:,
|
|
82
|
+
drag_handle: drag_handle_for(record),
|
|
83
|
+
position_group: position_group_for(record)
|
|
84
|
+
)
|
|
77
85
|
end
|
|
78
86
|
end
|
|
79
87
|
end
|
|
80
88
|
|
|
89
|
+
# The grid div is the tightest element containing every card and nothing
|
|
90
|
+
# else the drag cares about, so it is where the controller is scoped —
|
|
91
|
+
# exactly as Table::Base scopes it to the div wrapping <table>.
|
|
92
|
+
#
|
|
93
|
+
# Only wired when a drop can actually be honoured (see
|
|
94
|
+
# Positionable#position_drag_enabled?). Under any other sort the cards
|
|
95
|
+
# still render their grip, but as the disabled link back to position
|
|
96
|
+
# order, and no controller is present to drag against.
|
|
97
|
+
def grid_data
|
|
98
|
+
return {} unless position_drag_enabled?
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
controller: "positioned",
|
|
102
|
+
positioned_url_template_value: position_url_template,
|
|
103
|
+
# Cards flow left-to-right and wrap, so the vertical midpoint test a
|
|
104
|
+
# table uses would collapse a whole row of cards into one slot.
|
|
105
|
+
positioned_axis_value: "horizontal"
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The grip for `record`, or nil when this resource is not reorderable /
|
|
110
|
+
# this viewer may not reorder this record. `position_grip_sort_url` is
|
|
111
|
+
# memoized on the collection's sort, so the per-record cost here is just
|
|
112
|
+
# the policy check.
|
|
113
|
+
def drag_handle_for(record)
|
|
114
|
+
return nil unless position_config
|
|
115
|
+
return nil unless repositionable?(record)
|
|
116
|
+
|
|
117
|
+
Plutonium::UI::Table::Components::DragHandle.new(
|
|
118
|
+
sort_url: position_grip_sort_url,
|
|
119
|
+
variant: :card
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
81
123
|
# Default responsive: 1 / 2 / 3 / 4 columns at sm/md/lg/xl. When
|
|
82
124
|
# the definition pins a fixed `grid_columns N`, use that on lg+ so
|
|
83
125
|
# mobile still gets sensible single-column.
|
|
@@ -92,7 +134,7 @@ module Plutonium
|
|
|
92
134
|
|
|
93
135
|
def bulk_actions
|
|
94
136
|
@bulk_actions ||= resource_definition.defined_actions
|
|
95
|
-
.select { |k, a| a.bulk_action? }
|
|
137
|
+
.select { |k, a| a.bulk_action? && !a.hidden? && a.condition_met?(view_context) }
|
|
96
138
|
.values
|
|
97
139
|
end
|
|
98
140
|
|
|
@@ -124,7 +166,7 @@ module Plutonium
|
|
|
124
166
|
render Plutonium::UI::Table::Components::FilterForm.new(
|
|
125
167
|
filter_form_values,
|
|
126
168
|
query_object: current_query_object,
|
|
127
|
-
search_url:
|
|
169
|
+
search_url: current_query_object.request_path,
|
|
128
170
|
search_value: params.dig(:q, :search) || params[:search]
|
|
129
171
|
)
|
|
130
172
|
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Interaction
|
|
6
|
+
module Async
|
|
7
|
+
# The progress panel on a run's show page — the page IS the progress page.
|
|
8
|
+
#
|
|
9
|
+
# == How it refreshes
|
|
10
|
+
#
|
|
11
|
+
# Polling, not ActionCable: a turbo-frame the browser re-fetches on a timer
|
|
12
|
+
# works on any deployment, including ones with no cable server, and a run's
|
|
13
|
+
# progress is a handful of numbers that nobody needs sub-second. The frame
|
|
14
|
+
# is driven by the +run-progress+ Stimulus controller, which lives INSIDE
|
|
15
|
+
# the frame — so when the run finishes, the refreshed markup no longer
|
|
16
|
+
# carries the controller and the timer stops of its own accord. A finished
|
|
17
|
+
# run is a static record; a poll that never stops is a background request
|
|
18
|
+
# per viewer, forever.
|
|
19
|
+
#
|
|
20
|
+
# The frame's +src+ is the run's own show URL. Turbo sends
|
|
21
|
+
# +Turbo-Frame: <id>+ with that request, so the response must be the PANEL
|
|
22
|
+
# ALONE — Plutonium's DynaFrameContent already wraps every response in a
|
|
23
|
+
# frame named by that header, and a second tag with the same id would nest
|
|
24
|
+
# the whole page inside its own progress frame (and again on the next
|
|
25
|
+
# poll). {Async::RunDefinition::ShowPage} short-circuits the page for exactly this
|
|
26
|
+
# request; this class drops its own frame tag to match.
|
|
27
|
+
class RunProgress < Plutonium::UI::Component::Base
|
|
28
|
+
include Phlex::Rails::Helpers::TurboFrameTag
|
|
29
|
+
|
|
30
|
+
# Slow enough to be cheap for a page left open on a wall display, fast
|
|
31
|
+
# enough that a short run does not look stuck.
|
|
32
|
+
POLL_INTERVAL_MS = 2_000
|
|
33
|
+
|
|
34
|
+
# Must match the animation in components.css. Ruby needs it because the
|
|
35
|
+
# poll restarts the animation and only the server knows how far into
|
|
36
|
+
# the sweep the bar should be — see #sweep_phase_ms.
|
|
37
|
+
SWEEP_DURATION_MS = 1_400
|
|
38
|
+
|
|
39
|
+
# Semantic colour per outcome. +completed_with_errors+ is deliberately
|
|
40
|
+
# NOT success: see Plutonium::Interaction::Async::Run#outcome.
|
|
41
|
+
BADGE_VARIANTS = {
|
|
42
|
+
"pending" => "warning",
|
|
43
|
+
"running" => "info",
|
|
44
|
+
"completed" => "success",
|
|
45
|
+
"completed_with_errors" => "warning",
|
|
46
|
+
"failed" => "danger"
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Public so the page can recognise a poll of THIS panel and answer with
|
|
50
|
+
# the panel alone.
|
|
51
|
+
def self.frame_id(run) = "pu_run_progress_#{run.id}"
|
|
52
|
+
|
|
53
|
+
def initialize(run)
|
|
54
|
+
@run = run
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :run
|
|
58
|
+
|
|
59
|
+
def view_template
|
|
60
|
+
return render_panel if answering_own_frame?
|
|
61
|
+
|
|
62
|
+
# `turbo-frame` defaults to `display: inline`, which swallows the
|
|
63
|
+
# margin `sections_wrapper` needs to separate it from the card below.
|
|
64
|
+
turbo_frame_tag(frame_id, class: "block") { render_panel }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def frame_id = self.class.frame_id(run)
|
|
70
|
+
|
|
71
|
+
# True when this render IS the response to the frame's own poll.
|
|
72
|
+
def answering_own_frame? = current_turbo_frame == frame_id
|
|
73
|
+
|
|
74
|
+
# Reload once, so the fields outside this frame stop disagreeing with it.
|
|
75
|
+
def finished_attributes
|
|
76
|
+
# The STRING, not true: Phlex renders a boolean attribute bare, and a
|
|
77
|
+
# valueless data attribute is exactly the shape Stimulus's Boolean
|
|
78
|
+
# coercion is least clear about.
|
|
79
|
+
{data: {controller: "run-progress", run_progress_finished_value: "true"}}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_panel
|
|
83
|
+
div(class: "pu-card pu-run-progress", **poll_attributes) do
|
|
84
|
+
div(class: "pu-card-body space-y-4") do
|
|
85
|
+
render_status
|
|
86
|
+
render_progress
|
|
87
|
+
render_failures if run.errors_log.any?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Only an in-progress run carries the poll. Absent here, nothing on the
|
|
93
|
+
# page has a timer — which is how the refresh stops.
|
|
94
|
+
#
|
|
95
|
+
# Except on the LAST poll, which needs one more instruction. Only this
|
|
96
|
+
# panel lives in the polled frame; the fields beside it were rendered
|
|
97
|
+
# once, when the run was dispatched. So a run that finishes leaves the
|
|
98
|
+
# page showing two contradicting truths — a green "Completed, 5 of 5"
|
|
99
|
+
# above a list still reading "Running", "0 done", "finished at -" — and
|
|
100
|
+
# the stale half is the more detailed one. Telling the page to reload
|
|
101
|
+
# itself once is what lets those fields catch up.
|
|
102
|
+
#
|
|
103
|
+
# It cannot loop. The flag is emitted only while ANSWERING A POLL, and
|
|
104
|
+
# the reload it triggers is a full page render, where that is false.
|
|
105
|
+
def poll_attributes
|
|
106
|
+
return finished_attributes if !run.in_progress? && answering_own_frame?
|
|
107
|
+
return {} unless run.in_progress?
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
data: {
|
|
111
|
+
controller: "run-progress",
|
|
112
|
+
run_progress_url_value: resource_url_for(run),
|
|
113
|
+
run_progress_interval_value: POLL_INTERVAL_MS
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def render_status
|
|
119
|
+
div(class: "flex items-center gap-2 flex-wrap") do
|
|
120
|
+
span(class: "pu-badge pu-badge-#{BADGE_VARIANTS.fetch(run.outcome, "neutral")}") do
|
|
121
|
+
plain run.outcome.humanize
|
|
122
|
+
end
|
|
123
|
+
# The count rides alongside the badge rather than only in the list
|
|
124
|
+
# below: a partially-applied :continue run ends as "completed", and
|
|
125
|
+
# the one thing a reader must not be able to miss is that it did not
|
|
126
|
+
# do everything it was asked to.
|
|
127
|
+
if run.error_count.positive?
|
|
128
|
+
span(class: "text-xs font-medium text-[var(--pu-text-danger)]") do
|
|
129
|
+
plain "#{run.error_count} #{"error".pluralize(run.error_count)}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# NEVER rendered without {#render_status} above it. After a refused
|
|
136
|
+
# partial batch (:halt / :transactional), progress_done counts targets
|
|
137
|
+
# DISPOSITIONED, not applied — so a run that performed nothing at all can
|
|
138
|
+
# show progress_done > 0. Paired with the state that reads correctly;
|
|
139
|
+
# alone it reads as work done. See Async::Executor#refuse_partial_batch.
|
|
140
|
+
def render_progress
|
|
141
|
+
fraction = run.progress_fraction
|
|
142
|
+
|
|
143
|
+
# nil total is INDETERMINATE, not zero: opaque work has no denominator,
|
|
144
|
+
# and a 0% bar would claim nothing has happened rather than admitting
|
|
145
|
+
# the amount is unknown.
|
|
146
|
+
return render_indeterminate if fraction.nil?
|
|
147
|
+
|
|
148
|
+
percent = (fraction * 100).round
|
|
149
|
+
div(class: "space-y-1") do
|
|
150
|
+
div(class: "w-full h-2 rounded-full bg-[var(--pu-surface-alt)] overflow-hidden") do
|
|
151
|
+
div(class: "h-2 rounded-full bg-primary-600", style: "width: #{percent}%")
|
|
152
|
+
end
|
|
153
|
+
div(class: "text-xs text-[var(--pu-text-muted)]") do
|
|
154
|
+
plain "#{run.progress_done} of #{run.progress_total} targets (#{percent}%)"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Opaque work still gets a BAR, just one that admits it cannot say how
|
|
160
|
+
# far along it is. "Working…" as bare text read as a stalled page —
|
|
161
|
+
# nothing on it moved, so nothing said the run was alive. The animation
|
|
162
|
+
# is the part that carries that, and it is the only difference from the
|
|
163
|
+
# determinate bar above: same height, same track, same radius.
|
|
164
|
+
#
|
|
165
|
+
# A settled run with no total gets no bar, because there is no motion
|
|
166
|
+
# left to convey.
|
|
167
|
+
def render_indeterminate
|
|
168
|
+
div(class: "space-y-1") do
|
|
169
|
+
if run.in_progress?
|
|
170
|
+
div(class: "w-full h-2 rounded-full bg-[var(--pu-surface-alt)] overflow-hidden") do
|
|
171
|
+
div(
|
|
172
|
+
class: "h-2 rounded-full bg-primary-600 pu-run-progress-indeterminate",
|
|
173
|
+
style: "animation-delay: -#{sweep_phase_ms}ms"
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
div(class: "text-xs text-[var(--pu-text-muted)]") do
|
|
178
|
+
plain(run.in_progress? ? "Working…" : "No progress total was recorded")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# How far into the sweep the bar should already be, as a NEGATIVE
|
|
184
|
+
# animation-delay.
|
|
185
|
+
#
|
|
186
|
+
# The poll re-renders this panel every POLL_INTERVAL_MS, and an element
|
|
187
|
+
# re-inserted into the DOM restarts its CSS animation from zero —
|
|
188
|
+
# marking it turbo-permanent does not help, because Turbo MOVES it and
|
|
189
|
+
# a move is a re-insertion. So a 1.4s sweep chopped every 2s snapped
|
|
190
|
+
# back to the left mid-pass, forever, making the one signal that says
|
|
191
|
+
# "this run is alive" look like a stutter.
|
|
192
|
+
#
|
|
193
|
+
# Restarting is therefore unavoidable; restarting at the WRONG PLACE is
|
|
194
|
+
# not. Anchoring the phase to the run's own clock means each restart
|
|
195
|
+
# picks up where the last render left off, and the sweep reads as
|
|
196
|
+
# continuous across every poll.
|
|
197
|
+
def sweep_phase_ms
|
|
198
|
+
started = run.started_at || run.created_at
|
|
199
|
+
return 0 unless started
|
|
200
|
+
|
|
201
|
+
((Time.current - started) * 1000).to_i % SWEEP_DURATION_MS
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def render_failures
|
|
205
|
+
div(class: "space-y-1") do
|
|
206
|
+
div(class: "text-[10px] font-semibold uppercase tracking-wider text-[var(--pu-text-muted)]") do
|
|
207
|
+
plain "Failures"
|
|
208
|
+
end
|
|
209
|
+
ul(class: "pu-run-progress-failures text-xs space-y-1") do
|
|
210
|
+
run.errors_log.each do |entry|
|
|
211
|
+
li(class: "text-[var(--pu-text-danger)]") { plain failure_text(entry) }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A nil target_id is the RUN-LEVEL sentinel (see AsyncRun#record_target_failure!),
|
|
218
|
+
# not a target whose id happens to be blank.
|
|
219
|
+
def failure_text(entry)
|
|
220
|
+
target_id = entry["target_id"]
|
|
221
|
+
target_id.nil? ? entry["message"].to_s : "##{target_id}: #{entry["message"]}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Interaction
|
|
6
|
+
module Async
|
|
7
|
+
# Lists the runs currently working on THIS resource, above its collection.
|
|
8
|
+
#
|
|
9
|
+
# == Why it exists
|
|
10
|
+
#
|
|
11
|
+
# A dispatch redirects to the run's own page, but a user who navigates away
|
|
12
|
+
# has no way back short of knowing the URL. The index of the resource being
|
|
13
|
+
# worked on is where they will look, so that is where the run has to be.
|
|
14
|
+
#
|
|
15
|
+
# == Why it queries on target_type
|
|
16
|
+
#
|
|
17
|
+
# +target_type+ is a real column rather than a key inside the +options+
|
|
18
|
+
# JSON precisely so this predicate can be indexed — see the migration's own
|
|
19
|
+
# comment. An opaque run has no +target_type+ at all and therefore appears
|
|
20
|
+
# on no resource index: it belongs to no resource.
|
|
21
|
+
#
|
|
22
|
+
# == Why it can render nothing
|
|
23
|
+
#
|
|
24
|
+
# An always-present empty banner is chrome that teaches the user to ignore
|
|
25
|
+
# the area it occupies. When nothing is running there is nothing to say.
|
|
26
|
+
class RunningBanner < Plutonium::UI::Component::Base
|
|
27
|
+
def initialize(runs:)
|
|
28
|
+
@runs = runs
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :runs
|
|
32
|
+
|
|
33
|
+
def view_template
|
|
34
|
+
return if runs.empty?
|
|
35
|
+
|
|
36
|
+
div(class: "pu-running-banner mb-3 rounded-[var(--pu-radius-md)] border border-[var(--pu-border)] bg-[var(--pu-surface-alt)] p-3 space-y-2") do
|
|
37
|
+
runs.each { |run| render_run(run) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def render_run(run)
|
|
44
|
+
div(class: "flex items-center justify-between gap-3 text-sm", data: {run_id: run.id}) do
|
|
45
|
+
div(class: "min-w-0") do
|
|
46
|
+
# The label carries the run's identity; the state carries what it
|
|
47
|
+
# is doing. Progress is deliberately NOT shown bare here: after a
|
|
48
|
+
# refused partial batch progress_done counts targets DISPOSITIONED
|
|
49
|
+
# rather than attempted, so a number without its state is
|
|
50
|
+
# misleading (see Async::Executor#refuse_partial_batch). The run's
|
|
51
|
+
# own page renders the pair.
|
|
52
|
+
span(class: "font-medium text-[var(--pu-text)]") { plain run.to_label }
|
|
53
|
+
plain " "
|
|
54
|
+
span(class: "text-[var(--pu-text-muted)]") { plain run.state.humanize }
|
|
55
|
+
end
|
|
56
|
+
a(href: resource_url_for(run), class: "pu-btn pu-btn-xs pu-btn-soft-primary shrink-0") do
|
|
57
|
+
plain "View progress"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -57,7 +57,8 @@ module Plutonium
|
|
|
57
57
|
card_fields: @card_fields,
|
|
58
58
|
# Escape the column's lazy turbo-frame: either "_top" (full page) or
|
|
59
59
|
# the document-wide remote-modal frame, both of which resolve outside
|
|
60
|
-
# the kanban-col-<key> frame.
|
|
60
|
+
# the kanban-col-<key> frame. Grid::Card keys the metadata-rail hiding
|
|
61
|
+
# off this frame — the modal frame here means a kanban card's modal.
|
|
61
62
|
show_turbo_frame: @show_turbo_frame
|
|
62
63
|
)
|
|
63
64
|
end
|
|
@@ -340,12 +340,7 @@ module Plutonium
|
|
|
340
340
|
url = resource_url_for(resource_class, interaction: col_action.key, ids: ids, return_to: @board_url)
|
|
341
341
|
label = col_action.label || col_action.key.to_s.humanize
|
|
342
342
|
|
|
343
|
-
link_to(
|
|
344
|
-
url,
|
|
345
|
-
class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
|
|
346
|
-
title: label,
|
|
347
|
-
data: column_action_link_data(col_action, registered)
|
|
348
|
-
) do
|
|
343
|
+
link_to(url, column_action_link_attributes(col_action, registered, label)) do
|
|
349
344
|
render col_action.icon.new(class: "h-4 w-4") if col_action.icon
|
|
350
345
|
plain label
|
|
351
346
|
end
|
|
@@ -353,6 +348,17 @@ module Plutonium
|
|
|
353
348
|
end
|
|
354
349
|
end
|
|
355
350
|
|
|
351
|
+
# Full attribute hash for a column-action link, funnelling the
|
|
352
|
+
# registered action's author `link:` bag through the action's own
|
|
353
|
+
# merge point like every other action surface.
|
|
354
|
+
def column_action_link_attributes(col_action, registered, label)
|
|
355
|
+
registered.link_attributes({
|
|
356
|
+
class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
|
|
357
|
+
title: label,
|
|
358
|
+
data: column_action_link_data(col_action, registered)
|
|
359
|
+
})
|
|
360
|
+
end
|
|
361
|
+
|
|
356
362
|
# Data attributes for a column-action link, honouring the interaction's
|
|
357
363
|
# shape. `immediate` actions (no inputs) POST straight to the commit route
|
|
358
364
|
# with a confirmation, executed directly; the rest open the form in the
|
|
@@ -75,13 +75,12 @@ module Plutonium
|
|
|
75
75
|
# the board carries the shared view switcher (Table | Grid | Board),
|
|
76
76
|
# search, scopes, and filters rather than rendering bare columns.
|
|
77
77
|
div(data: filter_panel_controller_data) do
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
div(id: "kanban-flash")
|
|
78
|
+
# NOTE: move-rejection toasts are appended to the LAYOUT's shared
|
|
79
|
+
# toast region (Plutonium::FLASH_REGION) rather than one the board
|
|
80
|
+
# owns. It sits outside every collection and column frame, so it
|
|
81
|
+
# survives the frame swaps a move triggers, and it is the same
|
|
82
|
+
# region the reposition drop uses — one flash target for the
|
|
83
|
+
# framework, not one per draggable surface.
|
|
85
84
|
render_scopes_pills
|
|
86
85
|
render_toolbar
|
|
87
86
|
|
|
@@ -27,9 +27,9 @@ module Plutonium
|
|
|
27
27
|
|
|
28
28
|
def html_attributes = {lang:, data_controller: "color-mode"}
|
|
29
29
|
|
|
30
|
-
def body_attributes = {class: "antialiased min-h-
|
|
30
|
+
def body_attributes = {class: "antialiased pu-min-h-viewport bg-[var(--pu-body)] text-[var(--pu-text)]"}
|
|
31
31
|
|
|
32
|
-
def main_attributes = {class: "p-4 min-h-
|
|
32
|
+
def main_attributes = {class: "p-4 pu-min-h-viewport"}
|
|
33
33
|
|
|
34
34
|
def render_head
|
|
35
35
|
head {
|
|
@@ -106,8 +106,15 @@ module Plutonium
|
|
|
106
106
|
}
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
+
# The page's toast region. The id makes it a stable turbo_stream append
|
|
110
|
+
# target, so any action that answers a stream (rather than rendering a
|
|
111
|
+
# layout that would consume `flash`) can surface a message without
|
|
112
|
+
# inventing a region of its own — the reposition drop's rejection toasts
|
|
113
|
+
# land here. Toasts are position:fixed, so the wrapper has no layout
|
|
114
|
+
# effect. Rendered once per full page load, outside every collection
|
|
115
|
+
# frame, so a stream that replaces a collection cannot blow it away.
|
|
109
116
|
def render_flash
|
|
110
|
-
render partial("flash")
|
|
117
|
+
div(id: Plutonium::FLASH_REGION) { render partial("flash") }
|
|
111
118
|
end
|
|
112
119
|
|
|
113
120
|
def render_before_content
|
|
@@ -66,6 +66,7 @@ module Plutonium
|
|
|
66
66
|
def render_trigger_button
|
|
67
67
|
button(
|
|
68
68
|
type: "button",
|
|
69
|
+
aria: {expanded: "false"},
|
|
69
70
|
data: {resource_drop_down_target: "trigger"},
|
|
70
71
|
class: "p-2 text-[var(--pu-text-muted)] rounded-[var(--pu-radius-md)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] focus:ring-2 focus:ring-[var(--pu-border)] transition-colors"
|
|
71
72
|
) do
|
|
@@ -127,6 +127,25 @@ module Plutonium
|
|
|
127
127
|
def render_after_footer
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
# Where a modal's "open full page" affordance points: THIS request,
|
|
131
|
+
# minus the params that only mean something inside a modal.
|
|
132
|
+
#
|
|
133
|
+
# Deliberately not bare `request.path`. That drops the whole query
|
|
134
|
+
# string, and some of it is load-bearing on the standalone page too —
|
|
135
|
+
# `return_to` (where to go after submitting) and `kanban_column`
|
|
136
|
+
# (which column a quick-add's new record belongs to) would both be
|
|
137
|
+
# silently lost, so the expanded form would submit to the wrong place
|
|
138
|
+
# or into no column.
|
|
139
|
+
#
|
|
140
|
+
# `kanban_modal` is the one param that must NOT survive: it marks a
|
|
141
|
+
# kanban card's modal, where the show page suppresses the metadata
|
|
142
|
+
# rail. Carried onto a full page it would keep metadata hidden there
|
|
143
|
+
# for no reason.
|
|
144
|
+
def open_full_page_url
|
|
145
|
+
query = request.query_parameters.except(Plutonium::KANBAN_MODAL_PARAM)
|
|
146
|
+
query.empty? ? request.path : "#{request.path}?#{query.to_query}"
|
|
147
|
+
end
|
|
148
|
+
|
|
130
149
|
def page_type = raise NotImplementedError, "#{self.class}#page_type"
|
|
131
150
|
end
|
|
132
151
|
end
|
|
@@ -27,7 +27,10 @@ module Plutonium
|
|
|
27
27
|
render modal_class.new(
|
|
28
28
|
title: page_title,
|
|
29
29
|
description: page_description,
|
|
30
|
-
size: current_definition.modal_size
|
|
30
|
+
size: current_definition.modal_size,
|
|
31
|
+
# Opens this form standalone in a NEW TAB (target=_blank), so the
|
|
32
|
+
# modal — and anything already typed into it — stays put.
|
|
33
|
+
open_full_url: open_full_page_url
|
|
31
34
|
) do
|
|
32
35
|
render partial("resource_form")
|
|
33
36
|
end
|
|
@@ -22,6 +22,40 @@ module Plutonium
|
|
|
22
22
|
path.empty? ? "/" : path
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# DOM id of the wrapper around the rendered collection (table OR grid;
|
|
26
|
+
# never the kanban board, which owns its own column frames).
|
|
27
|
+
#
|
|
28
|
+
# This is the turbo-stream target the reposition endpoint updates when a
|
|
29
|
+
# drop leaves the client's optimistic view stale, and the element the
|
|
30
|
+
# drag controllers (Tasks 7/8) scope themselves to. Resource-scoped so
|
|
31
|
+
# two collections of DIFFERENT resources on one page (a nested
|
|
32
|
+
# association panel inside a show page) don't collide.
|
|
33
|
+
def self.collection_dom_id(resource_class)
|
|
34
|
+
"pu-collection-#{resource_class.model_name.plural}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolves the index view to render. Extracted to a class method because
|
|
38
|
+
# the reposition endpoint has to re-render the SAME view the user is
|
|
39
|
+
# looking at, and re-deriving that from scratch in the controller would
|
|
40
|
+
# be a second, drifting copy of this precedence.
|
|
41
|
+
#
|
|
42
|
+
# Resolution order:
|
|
43
|
+
# 1. `?view=` URL param (so a shared link can pin a view)
|
|
44
|
+
# 2. The view-preference cookie (sticky per-resource selection)
|
|
45
|
+
# 3. The resource's `default_index_view` (which itself defaults to
|
|
46
|
+
# `index_views.first`)
|
|
47
|
+
def self.resolve_view(definition, resource_class, view_param, cookies)
|
|
48
|
+
enabled = definition.defined_index_views
|
|
49
|
+
|
|
50
|
+
requested = view_param&.to_sym
|
|
51
|
+
return requested if requested && enabled.include?(requested)
|
|
52
|
+
|
|
53
|
+
stored = cookies[view_cookie_name(resource_class)]&.to_sym
|
|
54
|
+
return stored if stored && enabled.include?(stored)
|
|
55
|
+
|
|
56
|
+
definition.default_index_view
|
|
57
|
+
end
|
|
58
|
+
|
|
25
59
|
private
|
|
26
60
|
|
|
27
61
|
def page_title
|
|
@@ -33,33 +67,50 @@ module Plutonium
|
|
|
33
67
|
end
|
|
34
68
|
|
|
35
69
|
def page_actions
|
|
36
|
-
super || current_definition.defined_actions.values.select { |a| a.resource_action? && !a.
|
|
70
|
+
super || current_definition.defined_actions.values.select { |a| a.resource_action? && !a.hidden? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
|
|
37
71
|
end
|
|
38
72
|
|
|
39
73
|
def render_default_content
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
74
|
+
# The board owns its own frames (and its own drop target), so it is
|
|
75
|
+
# rendered bare. Table and grid share one id'd wrapper: it is what the
|
|
76
|
+
# reposition endpoint streams into after a drop, so the surface the
|
|
77
|
+
# drag controller reads and the surface the server replaces are the
|
|
78
|
+
# same element in both views.
|
|
79
|
+
render_running_banner
|
|
80
|
+
|
|
81
|
+
return render partial("resource_kanban") if selected_view == :kanban
|
|
82
|
+
|
|
83
|
+
div(id: self.class.collection_dom_id(resource_class)) do
|
|
84
|
+
render partial((selected_view == :grid) ? "resource_grid" : "resource_table")
|
|
44
85
|
end
|
|
45
86
|
end
|
|
46
87
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
88
|
+
# Runs currently working on this resource, above the collection.
|
|
89
|
+
#
|
|
90
|
+
# Rendered OUTSIDE the collection wrapper on purpose: that div is what
|
|
91
|
+
# the reposition endpoint streams into after a drag, so anything inside
|
|
92
|
+
# it is destroyed on the next drop.
|
|
93
|
+
#
|
|
94
|
+
# Scoped through authorized_resource_scope, the same door every other
|
|
95
|
+
# cross-resource read goes through, so a run dispatched in another tenant
|
|
96
|
+
# can never appear here — the banner cannot be a way around the policy.
|
|
97
|
+
def render_running_banner
|
|
98
|
+
return unless Plutonium.configuration.async_interactions.enabled
|
|
99
|
+
# A shared resource_class can be registered in a portal that never
|
|
100
|
+
# registered Run — its "View progress" link would then 500 building
|
|
101
|
+
# a URL for a route that doesn't exist here.
|
|
102
|
+
return unless registered_resources.include?(Plutonium::Interaction::Async::Run)
|
|
55
103
|
|
|
56
|
-
|
|
57
|
-
|
|
104
|
+
runs = authorized_resource_scope(
|
|
105
|
+
Plutonium::Interaction::Async::Run,
|
|
106
|
+
relation: Plutonium::Interaction::Async::Run.for_target(resource_class).in_progress
|
|
107
|
+
).to_a
|
|
58
108
|
|
|
59
|
-
|
|
60
|
-
|
|
109
|
+
render Plutonium::UI::Interaction::Async::RunningBanner.new(runs: runs)
|
|
110
|
+
end
|
|
61
111
|
|
|
62
|
-
|
|
112
|
+
def selected_view
|
|
113
|
+
self.class.resolve_view(current_definition, resource_class, params[:view], helpers.cookies)
|
|
63
114
|
end
|
|
64
115
|
|
|
65
116
|
def page_type = :index_page
|
|
@@ -25,7 +25,11 @@ module Plutonium
|
|
|
25
25
|
render modal_class.new(
|
|
26
26
|
title: page_title,
|
|
27
27
|
description: page_description,
|
|
28
|
-
size: current_interactive_action.modal_size(current_definition)
|
|
28
|
+
size: current_interactive_action.modal_size(current_definition),
|
|
29
|
+
# The action's own GET path renders the standalone branch below,
|
|
30
|
+
# and opens in a NEW TAB, so the modal keeps whatever has already
|
|
31
|
+
# been typed into it.
|
|
32
|
+
open_full_url: open_full_page_url
|
|
29
33
|
) do
|
|
30
34
|
render_interactive_action_form
|
|
31
35
|
end
|
|
@@ -27,7 +27,10 @@ module Plutonium
|
|
|
27
27
|
render modal_class.new(
|
|
28
28
|
title: page_title,
|
|
29
29
|
description: page_description,
|
|
30
|
-
size: current_definition.modal_size
|
|
30
|
+
size: current_definition.modal_size,
|
|
31
|
+
# Opens this form standalone in a NEW TAB (target=_blank), so the
|
|
32
|
+
# modal — and anything already typed into it — stays put.
|
|
33
|
+
open_full_url: open_full_page_url
|
|
31
34
|
) do
|
|
32
35
|
render partial("resource_form")
|
|
33
36
|
end
|