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
|
@@ -5,6 +5,21 @@ module Plutonium
|
|
|
5
5
|
# Prefix used for nested resource routes to disambiguate from user-defined routes
|
|
6
6
|
NESTED_ROUTE_PREFIX = "nested_"
|
|
7
7
|
|
|
8
|
+
# Route default naming which nesting a request arrived through.
|
|
9
|
+
#
|
|
10
|
+
# The router already knows which nested route matched; without this the
|
|
11
|
+
# controller has to work it back out — reading an *_id parameter to find the
|
|
12
|
+
# parent, and scanning the path for a "nested_" segment to find the
|
|
13
|
+
# association. Neither survives a parent that contributes no id (a singular
|
|
14
|
+
# parent has none), and both are guesses about something the route could
|
|
15
|
+
# simply state.
|
|
16
|
+
#
|
|
17
|
+
# The value is the key into resource_route_config_lookup, which already
|
|
18
|
+
# holds the parent class and the association name. Carrying the key rather
|
|
19
|
+
# than the names themselves means nothing has to be looked up by string at
|
|
20
|
+
# request time.
|
|
21
|
+
NESTED_KEY_PARAM = :pu_nested_key
|
|
22
|
+
|
|
8
23
|
# MapperExtensions module provides additional functionality for route mapping in Plutonium applications.
|
|
9
24
|
#
|
|
10
25
|
# This module extends the functionality of Rails' routing mapper to support Plutonium-specific features,
|
|
@@ -19,6 +34,10 @@ module Plutonium
|
|
|
19
34
|
#
|
|
20
35
|
# @param resource [Class] The resource class to be registered.
|
|
21
36
|
# @param options [Hash] Additional options for resource registration.
|
|
37
|
+
# @option options [Array<Symbol>] :associations the associations to draw
|
|
38
|
+
# nested routes for. Omit to draw one for every routable association, or
|
|
39
|
+
# set +Plutonium.configuration.nested_association_routes = :declared+ to
|
|
40
|
+
# make omitting it mean none.
|
|
22
41
|
# @yield An optional block for additional resource configuration.
|
|
23
42
|
# @return [void]
|
|
24
43
|
def register_resource(resource, options = {}, &)
|
|
@@ -86,7 +105,7 @@ module Plutonium
|
|
|
86
105
|
concern route_config[:concern_name] do
|
|
87
106
|
send route_config[:route_type], route_config[:route_name], **route_config[:route_options] do
|
|
88
107
|
instance_exec(&route_config[:block]) if route_config[:block]
|
|
89
|
-
define_nested_resource_routes(resource)
|
|
108
|
+
define_nested_resource_routes(resource, route_config[:associations])
|
|
90
109
|
end
|
|
91
110
|
end
|
|
92
111
|
end
|
|
@@ -94,12 +113,21 @@ module Plutonium
|
|
|
94
113
|
# Defines nested resource routes for a given resource.
|
|
95
114
|
#
|
|
96
115
|
# @param resource [Class] The parent resource class.
|
|
116
|
+
# @param declared [Array<Symbol>, nil] the associations named by
|
|
117
|
+
# register_resource, or nil when it named none.
|
|
97
118
|
# @return [void]
|
|
98
|
-
def define_nested_resource_routes(resource)
|
|
119
|
+
def define_nested_resource_routes(resource, declared = nil)
|
|
120
|
+
allowed = nested_route_associations(resource, declared)
|
|
121
|
+
|
|
99
122
|
# has_many associations use plural routes
|
|
100
123
|
resource.routable_has_many_associations.each do |assoc_info|
|
|
124
|
+
next if allowed && !allowed.include?(assoc_info[:name])
|
|
125
|
+
|
|
101
126
|
base_config = route_set.resource_route_config_for(assoc_info[:plural])[0]
|
|
102
|
-
|
|
127
|
+
unless base_config
|
|
128
|
+
raise_unregistered_nested_association(resource, assoc_info) if declared
|
|
129
|
+
next
|
|
130
|
+
end
|
|
103
131
|
|
|
104
132
|
# Register with association-based key: "parent_plural/association_name"
|
|
105
133
|
# Force route_type: :resources — has_many associations always nest as a
|
|
@@ -110,30 +138,41 @@ module Plutonium
|
|
|
110
138
|
nested_config = base_config.merge(
|
|
111
139
|
route_type: :resources,
|
|
112
140
|
association_name: assoc_info[:name],
|
|
113
|
-
resource_class: assoc_info[:klass]
|
|
141
|
+
resource_class: assoc_info[:klass],
|
|
142
|
+
parent_class: resource
|
|
114
143
|
)
|
|
115
144
|
route_set.resource_route_config_lookup[nested_key] = nested_config
|
|
116
145
|
|
|
117
|
-
resources "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}",
|
|
146
|
+
resources "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}",
|
|
147
|
+
**base_config[:route_options].except(:path),
|
|
148
|
+
defaults: {NESTED_KEY_PARAM => nested_key} do
|
|
118
149
|
instance_exec(&base_config[:block]) if base_config[:block]
|
|
119
150
|
end
|
|
120
151
|
end
|
|
121
152
|
|
|
122
153
|
# has_one associations use singular routes
|
|
123
154
|
resource.routable_has_one_associations.each do |assoc_info|
|
|
155
|
+
next if allowed && !allowed.include?(assoc_info[:name])
|
|
156
|
+
|
|
124
157
|
base_config = route_set.resource_route_config_for(assoc_info[:plural])[0]
|
|
125
|
-
|
|
158
|
+
unless base_config
|
|
159
|
+
raise_unregistered_nested_association(resource, assoc_info) if declared
|
|
160
|
+
next
|
|
161
|
+
end
|
|
126
162
|
|
|
127
163
|
# Register with association-based key and singular route type
|
|
128
164
|
nested_key = "#{resource.model_name.plural}/#{assoc_info[:name]}"
|
|
129
165
|
nested_config = base_config.merge(
|
|
130
166
|
route_type: :resource,
|
|
131
167
|
association_name: assoc_info[:name],
|
|
132
|
-
resource_class: assoc_info[:klass]
|
|
168
|
+
resource_class: assoc_info[:klass],
|
|
169
|
+
parent_class: resource
|
|
133
170
|
)
|
|
134
171
|
route_set.resource_route_config_lookup[nested_key] = nested_config
|
|
135
172
|
|
|
136
|
-
resource "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}",
|
|
173
|
+
resource "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}",
|
|
174
|
+
**base_config[:route_options].except(:path),
|
|
175
|
+
defaults: {NESTED_KEY_PARAM => nested_key} do
|
|
137
176
|
original_collection = method(:collection)
|
|
138
177
|
define_singleton_method(:collection) { |&_| } # no-op for singular resources
|
|
139
178
|
instance_exec(&base_config[:block]) if base_config[:block]
|
|
@@ -142,6 +181,51 @@ module Plutonium
|
|
|
142
181
|
end
|
|
143
182
|
end
|
|
144
183
|
|
|
184
|
+
# The associations a parent may draw nested routes for.
|
|
185
|
+
#
|
|
186
|
+
# nil means every routable association, which is what `:detected` does for
|
|
187
|
+
# a registration that named none. An array is a closed list, and an empty
|
|
188
|
+
# one is a real answer: `:declared` with nothing named draws nothing.
|
|
189
|
+
#
|
|
190
|
+
# A declared name that is not a routable association fails the boot. The
|
|
191
|
+
# whole point of naming them is to stop guessing which routes exist, so a
|
|
192
|
+
# typo that silently draws one route fewer would defeat it.
|
|
193
|
+
#
|
|
194
|
+
# @param resource [Class] The parent resource class.
|
|
195
|
+
# @param declared [Array<Symbol>, nil] the associations named at registration.
|
|
196
|
+
# @return [Array<Symbol>, nil]
|
|
197
|
+
# @raise [ArgumentError] if a declared name is not a routable association.
|
|
198
|
+
def nested_route_associations(resource, declared)
|
|
199
|
+
if declared.nil?
|
|
200
|
+
return nil if Plutonium.configuration.nested_association_routes == :detected
|
|
201
|
+
|
|
202
|
+
return []
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
routable = (resource.routable_has_many_associations +
|
|
206
|
+
resource.routable_has_one_associations).map { |info| info[:name] }
|
|
207
|
+
unknown = declared - routable
|
|
208
|
+
if unknown.any?
|
|
209
|
+
raise ArgumentError,
|
|
210
|
+
"#{resource} is registered with `associations: #{declared.inspect}`, but " \
|
|
211
|
+
"#{unknown.map(&:inspect).join(", ")} #{(unknown.size == 1) ? "is not a" : "are not"} " \
|
|
212
|
+
"routable #{"association".pluralize(unknown.size)} on it. " \
|
|
213
|
+
"Available: #{routable.empty? ? "(none)" : routable.map(&:inspect).join(", ")}. " \
|
|
214
|
+
"Nested routes are drawn for has_many and has_one only."
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
declared
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# @raise [ArgumentError] always
|
|
221
|
+
def raise_unregistered_nested_association(resource, assoc_info)
|
|
222
|
+
raise ArgumentError,
|
|
223
|
+
"#{resource} declares `associations: [:#{assoc_info[:name]}]`, but " \
|
|
224
|
+
"#{assoc_info[:klass]} is not registered in this portal, so there is no " \
|
|
225
|
+
"route to nest under it. Add `register_resource #{assoc_info[:klass]}` " \
|
|
226
|
+
"alongside it, or drop :#{assoc_info[:name]} from the list."
|
|
227
|
+
end
|
|
228
|
+
|
|
145
229
|
# Defines member-level interactive actions.
|
|
146
230
|
#
|
|
147
231
|
# @return [void]
|
|
@@ -153,6 +237,10 @@ module Plutonium
|
|
|
153
237
|
as: :commit_interactive_record_action
|
|
154
238
|
get "kanban_move_form", action: :kanban_move_form, as: :kanban_move_form
|
|
155
239
|
post "kanban_move", action: :kanban_move, as: :kanban_move
|
|
240
|
+
# Drag-reorder drop target for table/grid surfaces. Mounted on every
|
|
241
|
+
# resource (like the kanban routes above); resources whose definition
|
|
242
|
+
# declares no `position_on` answer it with a 404.
|
|
243
|
+
post "reposition", action: :reposition, as: :reposition
|
|
156
244
|
end
|
|
157
245
|
end
|
|
158
246
|
|
|
@@ -205,6 +293,8 @@ module Plutonium
|
|
|
205
293
|
as: :wizard_record_action
|
|
206
294
|
post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_record_action,
|
|
207
295
|
as: :commit_wizard_record_action
|
|
296
|
+
delete "wizards/:wizard_name(/:token)", action: :cancel_wizard_record_action,
|
|
297
|
+
as: :cancel_wizard_record_action
|
|
208
298
|
end
|
|
209
299
|
end
|
|
210
300
|
|
|
@@ -225,6 +315,8 @@ module Plutonium
|
|
|
225
315
|
as: :wizard_resource_action
|
|
226
316
|
post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_resource_action,
|
|
227
317
|
as: :commit_wizard_resource_action
|
|
318
|
+
delete "wizards/:wizard_name(/:token)", action: :cancel_wizard_resource_action,
|
|
319
|
+
as: :cancel_wizard_resource_action
|
|
228
320
|
end
|
|
229
321
|
end
|
|
230
322
|
|
|
@@ -151,8 +151,22 @@ module Plutonium
|
|
|
151
151
|
route_type: options[:singular] ? :resource : :resources,
|
|
152
152
|
route_name: route_name,
|
|
153
153
|
concern_name: concern_name,
|
|
154
|
+
# The associations this resource draws nested routes for, or nil when
|
|
155
|
+
# the registration named none. nil is not "no associations": what
|
|
156
|
+
# silence means is decided by
|
|
157
|
+
# Plutonium.configuration.nested_association_routes.
|
|
158
|
+
associations: options[:associations]&.map(&:to_sym),
|
|
154
159
|
route_options: {
|
|
155
|
-
|
|
160
|
+
# model_name.collection, not resource.to_s.pluralize.underscore:
|
|
161
|
+
# the latter reads the RAW class name, ignoring a model that pins
|
|
162
|
+
# its own model_name to escape it — which is exactly what
|
|
163
|
+
# Plutonium::Interaction::Async::Run does, precisely so a namespace this
|
|
164
|
+
# deep inside the gem's own module doesn't leak into every URL and
|
|
165
|
+
# controller path. For every resource that does NOT override
|
|
166
|
+
# model_name, .collection derives the identical path .pluralize.underscore
|
|
167
|
+
# would have (verified: both give "blogging/posts" for Blogging::Post).
|
|
168
|
+
# `controller:` remains available to override either way.
|
|
169
|
+
controller: options[:controller] || resource.model_name.collection,
|
|
156
170
|
path: options[:singular] ? resource.model_name.singular : resource.model_name.collection,
|
|
157
171
|
concerns: %i[interactive_resource_actions]
|
|
158
172
|
},
|
|
@@ -109,6 +109,8 @@ module Plutonium
|
|
|
109
109
|
as: :"#{helper_name}_wizard", defaults: defaults
|
|
110
110
|
post "(/:token)/:step", to: "#{WIZARD_CONTROLLER_NAME}#update",
|
|
111
111
|
defaults: defaults
|
|
112
|
+
delete "(/:token)", to: "#{WIZARD_CONTROLLER_NAME}#cancel",
|
|
113
|
+
as: :"#{helper_name}_wizard_cancel", defaults: defaults
|
|
112
114
|
end
|
|
113
115
|
end
|
|
114
116
|
|
|
@@ -174,6 +176,8 @@ module Plutonium
|
|
|
174
176
|
as: :"#{helper_name}_wizard", defaults: defaults
|
|
175
177
|
post "(/:token)/:step", to: "public_wizards#update",
|
|
176
178
|
defaults: defaults
|
|
179
|
+
delete "(/:token)", to: "public_wizards#cancel",
|
|
180
|
+
as: :"#{helper_name}_wizard_cancel", defaults: defaults
|
|
177
181
|
end
|
|
178
182
|
end
|
|
179
183
|
end
|
|
@@ -24,7 +24,11 @@ module Plutonium
|
|
|
24
24
|
matrix.each do |action, allowed_roles|
|
|
25
25
|
roles.each do |role_sym, account_proc|
|
|
26
26
|
account = instance_exec(&account_proc)
|
|
27
|
-
|
|
27
|
+
# The record MUST be positional. ActionPolicy::Policy::Core#initialize is
|
|
28
|
+
# `def initialize(record = nil, *)`, so a `record:` keyword is swallowed by
|
|
29
|
+
# the splat and `record` stays nil — every record-dependent predicate would
|
|
30
|
+
# then be asserted against nil.
|
|
31
|
+
policy = policy_klass.new(record, user: account, **policy_context)
|
|
28
32
|
expected = allowed_roles.include?(role_sym)
|
|
29
33
|
actual = policy.public_send("#{action}?")
|
|
30
34
|
assert_equal expected, actual,
|
|
@@ -38,7 +42,7 @@ module Plutonium
|
|
|
38
42
|
policy_klass = policy_class_for(record)
|
|
39
43
|
policy_roles.each do |role_sym, account_proc|
|
|
40
44
|
account = instance_exec(&account_proc)
|
|
41
|
-
policy = policy_klass.new(record
|
|
45
|
+
policy = policy_klass.new(record.class, user: account, **policy_context)
|
|
42
46
|
scope = policy.apply_scope(record.class.all, type: :active_record_relation)
|
|
43
47
|
assert_kind_of ActiveRecord::Relation, scope, "relation_scope must return AR::Relation for #{role_sym}"
|
|
44
48
|
end
|
|
@@ -49,8 +49,10 @@ module Plutonium
|
|
|
49
49
|
def render_link
|
|
50
50
|
link_to(
|
|
51
51
|
url_with_return_to,
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
@action.link_attributes({
|
|
53
|
+
class: button_classes,
|
|
54
|
+
data: {turbo_frame: @action.turbo_frame(current_definition)}.merge(@extra_data)
|
|
55
|
+
})
|
|
54
56
|
) do
|
|
55
57
|
render_button_content
|
|
56
58
|
end
|
|
@@ -63,13 +65,13 @@ module Plutonium
|
|
|
63
65
|
name: :return_to, value: return_to_url,
|
|
64
66
|
class: "inline-block",
|
|
65
67
|
data: @extra_data,
|
|
66
|
-
form: {
|
|
68
|
+
form: @action.button_attributes({
|
|
67
69
|
data: {
|
|
68
70
|
turbo: @action.turbo,
|
|
69
71
|
turbo_confirm: @action.confirmation.presence,
|
|
70
72
|
turbo_frame: @action.turbo_frame(current_definition)
|
|
71
73
|
}
|
|
72
|
-
}
|
|
74
|
+
})
|
|
73
75
|
) do
|
|
74
76
|
span(class: button_classes) do
|
|
75
77
|
render_button_content
|
|
@@ -94,8 +96,8 @@ module Plutonium
|
|
|
94
96
|
link_attrs[:data][:turbo_confirm] = @action.confirmation if @action.confirmation
|
|
95
97
|
end
|
|
96
98
|
|
|
97
|
-
a(
|
|
98
|
-
render @action.icon.new(class: "w-4 h-4") if @action.icon
|
|
99
|
+
a(**@action.link_attributes(link_attrs)) do
|
|
100
|
+
render @action.icon.new(class: "w-4 h-4 shrink-0") if @action.icon
|
|
99
101
|
span { @action.label }
|
|
100
102
|
end
|
|
101
103
|
end
|
|
@@ -157,7 +159,10 @@ module Plutonium
|
|
|
157
159
|
if current_turbo_frame && current_parent
|
|
158
160
|
resource_url_for(current_parent, parent: nil)
|
|
159
161
|
else
|
|
160
|
-
request.original_url
|
|
162
|
+
# current_page_url, not request.original_url: a button re-rendered by
|
|
163
|
+
# a stream-producing POST (the reposition drop) must return the user
|
|
164
|
+
# to the page they are on, never to that POST-only endpoint.
|
|
165
|
+
current_page_url
|
|
161
166
|
end
|
|
162
167
|
end
|
|
163
168
|
|
|
@@ -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-2 w-48 origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)] overflow-hidden",
|
|
37
|
+
class: "hidden absolute right-0 z-50 mt-2 w-max min-w-48 max-w-[min(20rem,90vw)] origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)] overflow-hidden",
|
|
38
38
|
style: "box-shadow: var(--pu-shadow-lg)",
|
|
39
39
|
data: {resource_drop_down_target: "menu"}
|
|
40
40
|
) do
|
data/lib/plutonium/ui/block.rb
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
module Plutonium
|
|
2
2
|
module UI
|
|
3
|
+
# A plain card surface to wrap arbitrary content in.
|
|
4
|
+
#
|
|
5
|
+
# This is THE card primitive — it renders `pu-card` rather than hand-rolling
|
|
6
|
+
# a surface. It used to paint its own background, radius and
|
|
7
|
+
# `--pu-shadow-md` inline, which left it subtly out of step with every
|
|
8
|
+
# `pu-card` on the page: no border, and a heavier shadow. Anything wrapping
|
|
9
|
+
# a `pu-card` in a Block then stacked two cards and got a doubled shadow,
|
|
10
|
+
# while anything wrapping bare content got a borderless one — which is why
|
|
11
|
+
# the show page's details and its metadata rail used to sit on visibly
|
|
12
|
+
# different surfaces.
|
|
13
|
+
#
|
|
14
|
+
# It carries no outer margin: spacing between cards belongs to whatever
|
|
15
|
+
# stacks them (a `space-y-*` container), not to the card itself, so a
|
|
16
|
+
# single Block can't decide the rhythm of a list it doesn't know it's in.
|
|
17
|
+
# Extra classes may be merged in (`Block(class: "overflow-hidden")`) so a
|
|
18
|
+
# caller can adjust the card without re-declaring what a card is.
|
|
3
19
|
class Block < Plutonium::UI::Component::Base
|
|
20
|
+
def initialize(**options)
|
|
21
|
+
@class = options[:class]
|
|
22
|
+
end
|
|
23
|
+
|
|
4
24
|
def view_template(&)
|
|
5
25
|
raise ArgumentError, "Block requires a content block" unless block_given?
|
|
6
26
|
|
|
7
|
-
div class: "relative
|
|
27
|
+
div class: tokens("relative pu-card", @class) do
|
|
8
28
|
yield
|
|
9
29
|
end
|
|
10
30
|
end
|
|
@@ -4,17 +4,35 @@ module Plutonium
|
|
|
4
4
|
include Phlex::Rails::Helpers::ActionName
|
|
5
5
|
include Phlex::Rails::Helpers::LinkTo
|
|
6
6
|
|
|
7
|
+
# Shared styling for an inline breadcrumb label.
|
|
8
|
+
LABEL_CLASSES = "ms-1 md:ms-2 flex items-center min-w-0 text-sm font-medium " \
|
|
9
|
+
"text-[var(--pu-text-muted)] transition-colors"
|
|
10
|
+
LINK_CLASSES = "#{LABEL_CLASSES} hover:text-primary-600"
|
|
11
|
+
|
|
12
|
+
# Styling for the same label when it is rendered as a row in the overflow
|
|
13
|
+
# menu instead of inline. Full-row target, chevron-led to echo the
|
|
14
|
+
# inline separators.
|
|
15
|
+
MENU_LINK_CLASSES = "flex items-center gap-1.5 py-1.5 px-3 text-sm text-[var(--pu-text-muted)] " \
|
|
16
|
+
"hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors"
|
|
17
|
+
|
|
18
|
+
CHEVRON_INLINE_CLASSES = "rtl:rotate-180 block w-3 h-3 mx-1 shrink-0 text-[var(--pu-text-subtle)]"
|
|
19
|
+
CHEVRON_MENU_CLASSES = "rtl:rotate-180 block w-3 h-3 shrink-0 text-[var(--pu-text-subtle)]"
|
|
20
|
+
|
|
7
21
|
def view_template
|
|
22
|
+
# `overflow-hidden` keeps the un-collapsed trail from spilling in the
|
|
23
|
+
# instant before the controller's first reflow.
|
|
8
24
|
nav(
|
|
9
|
-
class: "flex py-3 mb-2",
|
|
10
|
-
aria_label: "Breadcrumb"
|
|
25
|
+
class: "flex min-w-0 py-3 mb-2 overflow-hidden",
|
|
26
|
+
aria_label: "Breadcrumb",
|
|
27
|
+
data: {controller: "breadcrumbs"}
|
|
11
28
|
) do
|
|
12
29
|
ol(
|
|
13
|
-
class: "
|
|
30
|
+
class: "flex flex-nowrap w-full min-w-0 items-center gap-1 md:gap-2",
|
|
31
|
+
data: {breadcrumbs_target: "list"}
|
|
14
32
|
) do
|
|
15
33
|
render_dashboard_link
|
|
16
|
-
|
|
17
|
-
|
|
34
|
+
render_overflow_menu if middle_items.any?
|
|
35
|
+
render_items
|
|
18
36
|
render_trailing_separator
|
|
19
37
|
end
|
|
20
38
|
end
|
|
@@ -22,14 +40,43 @@ module Plutonium
|
|
|
22
40
|
|
|
23
41
|
private
|
|
24
42
|
|
|
43
|
+
# Every segment between the dashboard link and the trailing separator, as
|
|
44
|
+
# a list of lambdas taking the CSS classes to render with. Collected up
|
|
45
|
+
# front so each one can be rendered twice — inline, and again as a row in
|
|
46
|
+
# the overflow menu that the controller reveals when it folds the inline
|
|
47
|
+
# twin away.
|
|
48
|
+
def breadcrumb_items
|
|
49
|
+
@breadcrumb_items ||= [].tap do |items|
|
|
50
|
+
collect_parent_breadcrumbs(items) if current_parent.present?
|
|
51
|
+
collect_resource_breadcrumbs(items) if resource_record?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The segments eligible to be folded into the overflow menu. The last one
|
|
56
|
+
# is never foldable — it is the deepest thing the trail knows about, which
|
|
57
|
+
# is the record on new/edit pages but the resource index on show pages,
|
|
58
|
+
# where the record itself is not part of the trail at all.
|
|
59
|
+
def middle_items
|
|
60
|
+
breadcrumb_items[0...-1]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_items
|
|
64
|
+
last_index = breadcrumb_items.size - 1
|
|
65
|
+
breadcrumb_items.each_with_index do |item, index|
|
|
66
|
+
render_breadcrumb_item(foldable: index < last_index) do
|
|
67
|
+
item.call(LINK_CLASSES)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
25
72
|
def render_dashboard_link
|
|
26
|
-
li(class: "
|
|
73
|
+
li(class: "flex items-center shrink-0") do
|
|
27
74
|
a(
|
|
28
75
|
href: root_path,
|
|
29
76
|
class: "inline-flex items-center text-sm font-medium text-[var(--pu-text-muted)] hover:text-primary-600 transition-colors"
|
|
30
77
|
) do
|
|
31
78
|
svg(
|
|
32
|
-
class: "w-3 h-3 me-2.5",
|
|
79
|
+
class: "w-3 h-3 shrink-0 sm:me-2.5",
|
|
33
80
|
aria_hidden: "true",
|
|
34
81
|
xmlns: "http://www.w3.org/2000/svg",
|
|
35
82
|
fill: "currentColor",
|
|
@@ -40,85 +87,170 @@ module Plutonium
|
|
|
40
87
|
"m19.707 9.293-2-2-7-7a1 1 0 0 0-1.414 0l-7 7-2 2a1 1 0 0 0 1.414 1.414L2 10.414V18a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h3a2 2 0 0 0 2-2v-7.586l.293.293a1 1 0 0 0 1.414-1.414Z"
|
|
41
88
|
)
|
|
42
89
|
end
|
|
43
|
-
plain "
|
|
90
|
+
span(class: "sr-only sm:not-sr-only") { plain "Dashboard" }
|
|
44
91
|
end
|
|
45
92
|
end
|
|
46
93
|
end
|
|
47
94
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
95
|
+
# Builds a segment renderer used in both places a segment can appear:
|
|
96
|
+
# inline in the trail, and — with `leading:` — as a chevron-led row in
|
|
97
|
+
# the overflow menu.
|
|
98
|
+
def segment(label, url = nil)
|
|
99
|
+
->(classes, leading: false) {
|
|
100
|
+
if url
|
|
101
|
+
link_to(url, class: classes) do
|
|
102
|
+
render_chevron_separator(CHEVRON_MENU_CLASSES) if leading
|
|
103
|
+
span(class: "truncate min-w-0") { plain label }
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
span(class: classes) do
|
|
107
|
+
render_chevron_separator(CHEVRON_MENU_CLASSES) if leading
|
|
108
|
+
span(class: "truncate min-w-0") { plain label }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def collect_parent_breadcrumbs(items)
|
|
115
|
+
# Parent Resource — an index link, where the parent has one. A parent
|
|
116
|
+
# registered `singular: true` defines no collection route, so asking for
|
|
117
|
+
# one raises on a helper that was never generated.
|
|
118
|
+
unless singular_resource_route_for?(current_parent.class)
|
|
119
|
+
items << segment(
|
|
120
|
+
resource_name_plural(current_parent.class),
|
|
121
|
+
resource_url_for(current_parent.class, parent: nil)
|
|
122
|
+
)
|
|
54
123
|
end
|
|
55
124
|
|
|
56
125
|
# Parent Itself
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
end
|
|
126
|
+
items << segment(
|
|
127
|
+
display_name_of(current_parent),
|
|
128
|
+
resource_url_for(current_parent, parent: nil)
|
|
129
|
+
)
|
|
62
130
|
end
|
|
63
131
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
132
|
+
def collect_resource_breadcrumbs(items)
|
|
133
|
+
# Ask the controller, not the top-level route registry: a has_one
|
|
134
|
+
# association nests as a singular route regardless of how the child was
|
|
135
|
+
# registered, and only the controller's config lookup is keyed by
|
|
136
|
+
# "parent/association" and therefore knows that.
|
|
137
|
+
if singular_resource_context?
|
|
138
|
+
collect_singular_resource_breadcrumb(items)
|
|
69
139
|
else
|
|
70
|
-
|
|
140
|
+
collect_plural_resource_breadcrumbs(items)
|
|
71
141
|
end
|
|
72
142
|
end
|
|
73
143
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
span(class: "ms-1 text-sm font-medium text-[var(--pu-text-muted)] md:ms-2") do
|
|
82
|
-
plain resource_name(resource_class)
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
144
|
+
def collect_singular_resource_breadcrumb(items)
|
|
145
|
+
# A singular resource has no index, so the only segment it can add is
|
|
146
|
+
# itself — and on `show` the page title already names it, exactly as the
|
|
147
|
+
# plural branch omits the record on its own show page.
|
|
148
|
+
return unless resource_record!.persisted? && action_name != "show"
|
|
149
|
+
|
|
150
|
+
items << segment(resource_name(resource_class), resource_url_for(resource_record!))
|
|
86
151
|
end
|
|
87
152
|
|
|
88
|
-
def
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
153
|
+
def collect_plural_resource_breadcrumbs(items)
|
|
154
|
+
# No `parent:` here on purpose: Resource::Controller#resource_url_args_for
|
|
155
|
+
# already defaults it to `current_parent` (and supplies
|
|
156
|
+
# `current_nested_association` with it), so both links nest themselves
|
|
157
|
+
# when the request arrived through a nesting. Passing it explicitly reads
|
|
158
|
+
# like it changes something; it doesn't. The parent segments above pass
|
|
159
|
+
# `parent: nil` precisely to opt OUT of that default.
|
|
160
|
+
items << segment(
|
|
161
|
+
nestable_resource_name_plural(resource_class),
|
|
162
|
+
resource_url_for(resource_class)
|
|
163
|
+
)
|
|
95
164
|
|
|
96
165
|
# Record itself (for non-singular routes only)
|
|
97
166
|
return unless resource_record!.persisted? && action_name != "show"
|
|
98
167
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
168
|
+
items << segment(display_name_of(resource_record!), resource_url_for(resource_record!))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Stands in for whatever the controller folded away, the way GitHub
|
|
172
|
+
# collapses a long path. Starts hidden — it only earns its place once at
|
|
173
|
+
# least one segment has actually been folded.
|
|
174
|
+
def render_overflow_menu
|
|
175
|
+
li(
|
|
176
|
+
class: "hidden items-center shrink-0",
|
|
177
|
+
data: {breadcrumbs_target: "overflow"}
|
|
178
|
+
) do
|
|
179
|
+
render_chevron_separator
|
|
180
|
+
div(
|
|
181
|
+
class: "flex items-center",
|
|
182
|
+
data: {
|
|
183
|
+
controller: "resource-drop-down",
|
|
184
|
+
resource_drop_down_placement_value: "bottom-start"
|
|
185
|
+
}
|
|
186
|
+
) do
|
|
187
|
+
render_overflow_trigger
|
|
188
|
+
render_overflow_items
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def render_overflow_trigger
|
|
194
|
+
button(
|
|
195
|
+
type: "button",
|
|
196
|
+
aria: {label: "Show collapsed breadcrumbs", haspopup: "true", expanded: "false"},
|
|
197
|
+
data: {resource_drop_down_target: "trigger"},
|
|
198
|
+
class: "flex items-center justify-center ms-1 w-6 h-6 rounded-[var(--pu-radius-md)] " \
|
|
199
|
+
"text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors"
|
|
200
|
+
) do
|
|
201
|
+
render Phlex::TablerIcons::Dots.new(class: "w-4 h-4")
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# One row per foldable segment. Each starts hidden; the controller reveals
|
|
206
|
+
# exactly those whose inline twin it folded away.
|
|
207
|
+
def render_overflow_items
|
|
208
|
+
div(
|
|
209
|
+
# No margin here — the dropdown controller already offsets the popper
|
|
210
|
+
# off its trigger.
|
|
211
|
+
class: "hidden z-50 py-1 min-w-40 max-w-[70vw] bg-[var(--pu-surface)] " \
|
|
212
|
+
"border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden",
|
|
213
|
+
style: "box-shadow: var(--pu-shadow-lg)",
|
|
214
|
+
data: {resource_drop_down_target: "menu"}
|
|
215
|
+
) do
|
|
216
|
+
ul(class: "list-none") do
|
|
217
|
+
middle_items.each do |item|
|
|
218
|
+
li(class: "hidden", data: {breadcrumbs_target: "menuItem"}) do
|
|
219
|
+
item.call(MENU_LINK_CLASSES, leading: true)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
103
223
|
end
|
|
104
224
|
end
|
|
105
225
|
|
|
106
226
|
def render_trailing_separator
|
|
107
|
-
li(class: "flex items-center") do
|
|
227
|
+
li(class: "flex items-center shrink-0") do
|
|
108
228
|
render_chevron_separator
|
|
109
229
|
end
|
|
110
230
|
end
|
|
111
231
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
232
|
+
# Segments are `shrink-0` so the controller measures natural widths and
|
|
233
|
+
# can detect real overflow. The last one is additionally `min-w-0` so the
|
|
234
|
+
# controller can drop its `shrink-0` and let it ellipsize once there is
|
|
235
|
+
# nothing left to fold. Its `last` target must stay in lockstep with
|
|
236
|
+
# `middle_items` — the controller pairs inline twins to menu rows by index.
|
|
237
|
+
def render_breadcrumb_item(foldable: false, &)
|
|
238
|
+
if foldable
|
|
239
|
+
li(class: "flex items-center shrink-0", data: {breadcrumbs_target: "item"}) do
|
|
240
|
+
render_chevron_separator
|
|
241
|
+
yield
|
|
242
|
+
end
|
|
243
|
+
else
|
|
244
|
+
li(class: "flex items-center shrink-0 min-w-0", data: {breadcrumbs_target: "last"}) do
|
|
245
|
+
render_chevron_separator
|
|
246
|
+
yield
|
|
247
|
+
end
|
|
116
248
|
end
|
|
117
249
|
end
|
|
118
250
|
|
|
119
|
-
def render_chevron_separator
|
|
251
|
+
def render_chevron_separator(classes = CHEVRON_INLINE_CLASSES)
|
|
120
252
|
svg(
|
|
121
|
-
class:
|
|
253
|
+
class: classes,
|
|
122
254
|
aria_hidden: "true",
|
|
123
255
|
xmlns: "http://www.w3.org/2000/svg",
|
|
124
256
|
fill: "none",
|