plutonium 0.62.2 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/.standard.yml +1 -1
- data/CHANGELOG.md +73 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +323 -141
- data/gemfiles/rails_8.0.gemfile.lock +126 -116
- data/gemfiles/rails_8.1.gemfile.lock +127 -117
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +20 -20
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +101 -22
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -31,6 +31,8 @@ module Plutonium
|
|
|
31
31
|
:resource_class,
|
|
32
32
|
:resource_record!,
|
|
33
33
|
:resource_record?,
|
|
34
|
+
:singular_resource_context?,
|
|
35
|
+
:singular_resource_route_for?,
|
|
34
36
|
:resource_name,
|
|
35
37
|
:resource_name_plural,
|
|
36
38
|
:nestable_resource_name_plural,
|
|
@@ -48,6 +50,7 @@ module Plutonium
|
|
|
48
50
|
:in_frame?,
|
|
49
51
|
:in_modal?,
|
|
50
52
|
:in_secondary_modal?,
|
|
53
|
+
:in_kanban_modal?,
|
|
51
54
|
:turbo_scoped_dom_id,
|
|
52
55
|
:current_interactive_action,
|
|
53
56
|
:current_engine,
|
|
@@ -58,6 +61,8 @@ module Plutonium
|
|
|
58
61
|
:root_path,
|
|
59
62
|
:make_page_title,
|
|
60
63
|
:resource_logo_tag,
|
|
64
|
+
:current_page_path,
|
|
65
|
+
:current_page_url,
|
|
61
66
|
to: :view_context
|
|
62
67
|
end
|
|
63
68
|
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Component
|
|
6
|
+
# Drag-to-reorder plumbing shared by every index surface that renders a
|
|
7
|
+
# collection of a `position_on` resource — the table (Table::Resource) and
|
|
8
|
+
# the card grid (Grid::Resource).
|
|
9
|
+
#
|
|
10
|
+
# Extracted rather than copied because the two halves of this feature have
|
|
11
|
+
# to agree EXACTLY: the client only offers a drag under the same condition
|
|
12
|
+
# the server accepts one, and the URL template the client substitutes into
|
|
13
|
+
# has to be the collection's own path. Two copies of that reasoning is two
|
|
14
|
+
# places for the client and the server to drift apart.
|
|
15
|
+
#
|
|
16
|
+
# A host must supply `resource_definition`, `current_query_object`,
|
|
17
|
+
# `current_page_path` and `policy_for` — everything Component::Base's
|
|
18
|
+
# index views already have.
|
|
19
|
+
module Positionable
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# The resource's positioning strategy, or nil when it declares none /
|
|
23
|
+
# declares `position_on false`. Everything else here hangs off this.
|
|
24
|
+
def position_config
|
|
25
|
+
return @position_config if defined?(@position_config)
|
|
26
|
+
|
|
27
|
+
config = resource_definition.defined_position_config
|
|
28
|
+
@position_config = (config && !config.disabled?) ? config : nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether a drop can be honoured RIGHT NOW.
|
|
32
|
+
#
|
|
33
|
+
# Dropping "between the two records either side of me" only describes a
|
|
34
|
+
# position when the visual order IS the stored order. Under a title sort
|
|
35
|
+
# the neighbours say nothing; under a DESCENDING position sort they say
|
|
36
|
+
# the opposite of what the write would assume. The server rejects both
|
|
37
|
+
# (422, no write) — this is the client half of that same rule, and it
|
|
38
|
+
# deliberately reuses the server's own predicate rather than restating it.
|
|
39
|
+
def position_drag_enabled?
|
|
40
|
+
return false unless position_config
|
|
41
|
+
|
|
42
|
+
current_query_object.sorted_ascending_only_by?(position_config.attribute)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The URL that puts the collection back into ascending position order —
|
|
46
|
+
# what the disabled grip links to. Built off the registered sort (see
|
|
47
|
+
# Definition::Positioning#position_on), which flips to ASC from a
|
|
48
|
+
# descending position sort and starts at ASC from any other sort.
|
|
49
|
+
def position_sort_url
|
|
50
|
+
current_query_object.sort_params_for(position_config.attribute)[:url]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# What to hand a grip: nil while dragging is live, otherwise the sort URL
|
|
54
|
+
# that makes it live. Memoized because it depends on the collection's
|
|
55
|
+
# sort, not on the record — re-deriving it per row would rebuild the same
|
|
56
|
+
# URL for every row of every page.
|
|
57
|
+
def position_grip_sort_url
|
|
58
|
+
return @position_grip_sort_url if defined?(@position_grip_sort_url)
|
|
59
|
+
|
|
60
|
+
@position_grip_sort_url = position_drag_enabled? ? nil : position_sort_url
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The member reposition path with an __ID__ placeholder for the client to
|
|
64
|
+
# substitute. Mirrors Kanban::Resource#kanban_move_url_template, but off
|
|
65
|
+
# `current_page_path` rather than `request.path`: after a rebalance the
|
|
66
|
+
# endpoint re-renders this very collection from a POST to
|
|
67
|
+
# /things/5/reposition, and a template derived from THAT request would
|
|
68
|
+
# send every subsequent drop to a nested path that does not exist.
|
|
69
|
+
def position_url_template
|
|
70
|
+
"#{current_page_path.delete_suffix("/")}/__ID__/reposition"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Whether THIS viewer may reorder THIS record. Has to run per record —
|
|
74
|
+
# it is what keeps someone who may not reorder a given record from being
|
|
75
|
+
# offered a grip that can only ever answer 403.
|
|
76
|
+
def repositionable?(record)
|
|
77
|
+
policy_for(record:).allowed_to?(:reposition?)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Which positioning GROUP a record sits in, or nil when every record on
|
|
81
|
+
# the surface shares one list.
|
|
82
|
+
#
|
|
83
|
+
# A scoped resource (`positioned_on :position, scope: :product_id`) keeps
|
|
84
|
+
# an independent 1..n sequence per group, so a top-level index that spans
|
|
85
|
+
# groups interleaves several sequences. A drop between two records from
|
|
86
|
+
# DIFFERENT groups describes no position at all, and the server refuses
|
|
87
|
+
# it (see PositionActions#resolve_position_neighbour, which rejects a
|
|
88
|
+
# neighbour whose scope attribute differs). This is the client half of
|
|
89
|
+
# that same rule — the value goes on the row so the drag can refuse
|
|
90
|
+
# before the user commits, instead of snapping back afterwards.
|
|
91
|
+
#
|
|
92
|
+
# Guarded on `delegate?` exactly as the server is: Mode B owns its own
|
|
93
|
+
# notion of a group, and nothing out here may second-guess it.
|
|
94
|
+
def position_group_for(record)
|
|
95
|
+
return nil unless position_config&.delegate?
|
|
96
|
+
|
|
97
|
+
attr = record.class.positioning_scope_attr
|
|
98
|
+
attr && record[attr]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Passed to the collection components, which have no access to the
|
|
102
|
+
# definition and must not grow any. nil when the resource is unscoped, so
|
|
103
|
+
# the attribute is omitted entirely rather than emitted empty.
|
|
104
|
+
def position_group_resolver
|
|
105
|
+
return nil unless position_config&.delegate?
|
|
106
|
+
|
|
107
|
+
method(:position_group_for)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Component
|
|
6
|
+
# Turns a resolved `as:` into a component, for every surface that renders a
|
|
7
|
+
# field: forms, displays, tables, filter forms and the wizard summary.
|
|
8
|
+
#
|
|
9
|
+
# `as:` is a union — a built-in alias (Symbol/String, dispatched to the
|
|
10
|
+
# builder's `#{alias}_tag` method) or a component Class rendered directly
|
|
11
|
+
# (see {Plutonium::Definition::InputAliases}). Every surface has to handle
|
|
12
|
+
# BOTH, so the branch lives here instead of being re-implemented per view —
|
|
13
|
+
# a table or filter form that hand-rolled only the alias half raised
|
|
14
|
+
# `NoMethodError: MyPickerComponent_tag` on an `as:` a form rendered
|
|
15
|
+
# happily.
|
|
16
|
+
#
|
|
17
|
+
# A component Class is instantiated with the field builder (the contract
|
|
18
|
+
# every built-in tag component follows), so `as:` takes FIELD components; a
|
|
19
|
+
# component with its own constructor goes through the block form
|
|
20
|
+
# (`input :x do |field| MyComponent.new(value: field.value) end`).
|
|
21
|
+
#
|
|
22
|
+
# Mixed into the form and display field builders, so it is available on
|
|
23
|
+
# every `f` a view yields (including the table's display builder).
|
|
24
|
+
module ResolvesTags
|
|
25
|
+
# @param tag [Symbol, String, Class, nil] a built-in alias, a component
|
|
26
|
+
# class, or nil to infer the tag from the field's type.
|
|
27
|
+
# @param attributes [Hash] tag attributes. Reach a component class the
|
|
28
|
+
# same way they reach an alias tag — via `Components::Base#attributes`
|
|
29
|
+
# — so `as:` declarations honour the surface's own attributes (the
|
|
30
|
+
# filter panel's `class: "w-full"`, a form's `pre_submit` action)
|
|
31
|
+
# instead of silently dropping them.
|
|
32
|
+
# @return [Phlex::SGML] the component to render.
|
|
33
|
+
def component_for(tag, **attributes, &)
|
|
34
|
+
tag ||= inferred_field_component
|
|
35
|
+
return create_component(tag, component_theme_key(tag), **attributes, &) if tag.is_a?(Class)
|
|
36
|
+
|
|
37
|
+
send(:"#{tag}_tag", **attributes, &)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Theme key for a directly-rendered component class:
|
|
43
|
+
# `Admin::ColorPickerComponent` themes off `:color_picker`.
|
|
44
|
+
#
|
|
45
|
+
# The `_?` matters — dropping a bare `component$` left the separator
|
|
46
|
+
# behind, so the key was `:color_picker_` and a theme entry written for
|
|
47
|
+
# the obvious `:color_picker` silently never matched. Nothing in the
|
|
48
|
+
# framework keys off this (built-in tags pass their key to
|
|
49
|
+
# `create_component` explicitly); it is reached only for a user's `as:`
|
|
50
|
+
# class, which is exactly the case that was broken.
|
|
51
|
+
def component_theme_key(component_class)
|
|
52
|
+
component_class.name.demodulize.underscore.sub(/_?component$/, "").to_sym
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Component
|
|
6
|
+
# Shared chrome for a declared layout section — a card with a header row
|
|
7
|
+
# (accent bar, heading, optional description, optional collapse caret)
|
|
8
|
+
# over a body that holds the fields. Subclassed (not copied) by both
|
|
9
|
+
# {Plutonium::UI::Form::Components::Section} and
|
|
10
|
+
# {Plutonium::UI::Display::Components::Section} so a form section and a
|
|
11
|
+
# show-page section always read the same; the only difference between the
|
|
12
|
+
# two is which grid class the caller passes in.
|
|
13
|
+
#
|
|
14
|
+
# Each section is its OWN card rather than a band inside one shared card.
|
|
15
|
+
# The card boundary does the grouping — no amount of heading typography
|
|
16
|
+
# separates two field groups as unambiguously as an actual edge does, and
|
|
17
|
+
# the header row then only has to name the group rather than carry the
|
|
18
|
+
# whole structural signal. Callers therefore must NOT wrap sections in a
|
|
19
|
+
# card of their own; they supply a plain stacking container.
|
|
20
|
+
#
|
|
21
|
+
# The caller supplies the fields as a block and owns what a "field" means.
|
|
22
|
+
class Section < Plutonium::UI::Component::Base
|
|
23
|
+
# Default section chrome, merged into BOTH Form::Theme and
|
|
24
|
+
# Display::Theme so the two read identically out of the box while each
|
|
25
|
+
# stays independently overridable — the same contract every other
|
|
26
|
+
# themed key has. Defined here rather than written out twice so the
|
|
27
|
+
# shipped defaults cannot drift between the form and the show page.
|
|
28
|
+
#
|
|
29
|
+
# Structure (which element gets which key, the `group`/[open]
|
|
30
|
+
# mechanics) stays in this component; themes own the classes.
|
|
31
|
+
DEFAULT_THEME = {
|
|
32
|
+
# Merged into the section's {Plutonium::UI::Block} card — Block
|
|
33
|
+
# supplies `pu-card` itself, so this only adds what is specific to a
|
|
34
|
+
# section. `overflow-hidden` keeps the header row's fill and border
|
|
35
|
+
# inside the card's rounded corners.
|
|
36
|
+
section_wrapper: "overflow-hidden",
|
|
37
|
+
|
|
38
|
+
# Header row. Its bottom border is the line between the header and
|
|
39
|
+
# the fields — the card edge already separates one section from the
|
|
40
|
+
# next, so nothing else has to.
|
|
41
|
+
section_header:
|
|
42
|
+
"px-4 py-3 flex items-center gap-3 " \
|
|
43
|
+
"border-b border-[var(--pu-border)] bg-[var(--pu-surface-alt)]",
|
|
44
|
+
|
|
45
|
+
# A short primary bar at the head of the row. A standalone element
|
|
46
|
+
# rather than a border on the text block, so the heading and its
|
|
47
|
+
# description stay vertically centred against it.
|
|
48
|
+
section_accent: "shrink-0 w-1 h-5 rounded-full bg-primary-500",
|
|
49
|
+
|
|
50
|
+
# Sized to sit under the page title (text-xl semibold) while
|
|
51
|
+
# out-ranking display values (text-lg regular) and field labels.
|
|
52
|
+
# With the card doing the separating, the heading no longer has to
|
|
53
|
+
# shout to be read as a header.
|
|
54
|
+
section_heading: "text-base font-bold tracking-tight text-[var(--pu-text)]",
|
|
55
|
+
section_description: "text-sm font-normal text-[var(--pu-text-muted)]",
|
|
56
|
+
|
|
57
|
+
# `list-none` + the WebKit marker reset remove the native disclosure
|
|
58
|
+
# triangle, which the browser pins to the LEFT of the summary — where
|
|
59
|
+
# it would displace the accent bar and knock the heading out of the
|
|
60
|
+
# alignment every other section keeps. The caret is re-drawn on the
|
|
61
|
+
# right instead, so headers stay identical whether or not a section
|
|
62
|
+
# happens to be collapsible.
|
|
63
|
+
# The divider only exists to separate the header from the fields, so
|
|
64
|
+
# a COLLAPSED section must not draw one — there is nothing below it,
|
|
65
|
+
# and the line would land directly on the card's own bottom border.
|
|
66
|
+
# `group-open:` keys it to the parent <details>'s [open] state.
|
|
67
|
+
section_summary:
|
|
68
|
+
"px-4 py-3 flex items-center gap-3 cursor-pointer select-none " \
|
|
69
|
+
"border-b-0 group-open:border-b border-[var(--pu-border)] " \
|
|
70
|
+
"bg-[var(--pu-surface-alt)] " \
|
|
71
|
+
"list-none [&::-webkit-details-marker]:hidden",
|
|
72
|
+
|
|
73
|
+
section_caret:
|
|
74
|
+
"shrink-0 w-3 h-3 text-[var(--pu-text-muted)] " \
|
|
75
|
+
"transition-transform duration-200 group-open:rotate-180",
|
|
76
|
+
|
|
77
|
+
# Padding box between the card edge and the field grid. Roomier than
|
|
78
|
+
# the stock `pu-card-body` (16px): a section card holds inputs, not
|
|
79
|
+
# the dense label/value rows a card body is sized for, and the form
|
|
80
|
+
# this replaced was a single `p-8` card — at 16px the fields sit
|
|
81
|
+
# noticeably tighter against the edge than they used to.
|
|
82
|
+
section_body: "p-6"
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
# The theme this section's chrome resolves against. Subclasses name
|
|
86
|
+
# their own, so a form section follows Form::Theme and a show-page
|
|
87
|
+
# section follows Display::Theme.
|
|
88
|
+
def self.theme_class
|
|
89
|
+
raise NotImplementedError, "#{self} must implement .theme_class"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def initialize(resolved, grid_class:)
|
|
93
|
+
@section = resolved.section
|
|
94
|
+
@grid_class = grid_class
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Every section is a {Plutonium::UI::Block} — the shared card primitive
|
|
98
|
+
# — so a section card and any other card on the page are the same
|
|
99
|
+
# surface by construction rather than by two lists of classes that
|
|
100
|
+
# happen to agree today.
|
|
101
|
+
def view_template(&fields_block)
|
|
102
|
+
Block(class: themed_section(:section_wrapper)) do
|
|
103
|
+
if @section.collapsible?
|
|
104
|
+
# `group` lets the caret rotate off the <details>'s [open] state.
|
|
105
|
+
details(open: !@section.collapsed?, class: "group") do
|
|
106
|
+
# <summary> must be the first child of <details> and can't be
|
|
107
|
+
# wrapped, so it IS the header row.
|
|
108
|
+
summary(class: themed_section(:section_summary)) do
|
|
109
|
+
span(class: themed_section(:section_accent))
|
|
110
|
+
heading_block
|
|
111
|
+
render_caret
|
|
112
|
+
end
|
|
113
|
+
body(&fields_block)
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
header_row
|
|
117
|
+
body(&fields_block)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def themed_section(key)
|
|
125
|
+
self.class.theme_class.instance.resolve_theme(key)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# An unlabelled `ungrouped` bucket gets no header row at all — just a
|
|
129
|
+
# card holding its leftover fields.
|
|
130
|
+
def header_row
|
|
131
|
+
return if headerless?
|
|
132
|
+
div(class: themed_section(:section_header)) do
|
|
133
|
+
span(class: themed_section(:section_accent))
|
|
134
|
+
heading_block
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def headerless? = @section.ungrouped? && @section.options[:label].nil?
|
|
139
|
+
|
|
140
|
+
# Title over description, taking the free space in the row so the caret
|
|
141
|
+
# is pushed to the far right.
|
|
142
|
+
def heading_block
|
|
143
|
+
div(class: "min-w-0 flex-1") do
|
|
144
|
+
h3(class: themed_section(:section_heading)) { heading_text }
|
|
145
|
+
describe
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def heading_text = @section.label
|
|
150
|
+
|
|
151
|
+
def describe
|
|
152
|
+
return unless @section.description
|
|
153
|
+
p(class: themed_section(:section_description)) { @section.description }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Points down when open, up when closed (rotate-180). Decorative — the
|
|
157
|
+
# <summary> element already carries the expand/collapse semantics for
|
|
158
|
+
# assistive tech, so this is aria-hidden.
|
|
159
|
+
def render_caret
|
|
160
|
+
svg(
|
|
161
|
+
class: themed_section(:section_caret),
|
|
162
|
+
aria_hidden: "true",
|
|
163
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
164
|
+
fill: "none",
|
|
165
|
+
viewbox: "0 0 10 6"
|
|
166
|
+
) do |s|
|
|
167
|
+
s.path(
|
|
168
|
+
stroke: "currentColor",
|
|
169
|
+
stroke_linecap: "round",
|
|
170
|
+
stroke_linejoin: "round",
|
|
171
|
+
stroke_width: "2",
|
|
172
|
+
d: "M1 1l4 4 4-4"
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def body(&fields_block)
|
|
178
|
+
div(class: themed_section(:section_body)) do
|
|
179
|
+
div(class: @grid_class, &fields_block)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -8,6 +8,7 @@ module Plutonium
|
|
|
8
8
|
|
|
9
9
|
class Builder < Builder
|
|
10
10
|
include Plutonium::UI::Display::Options::InferredTypes
|
|
11
|
+
include Plutonium::UI::Component::ResolvesTags
|
|
11
12
|
|
|
12
13
|
def association_tag(**, &)
|
|
13
14
|
create_component(Plutonium::UI::Display::Components::Association, :association, **, &)
|
|
@@ -26,6 +27,13 @@ module Plutonium
|
|
|
26
27
|
create_component(Plutonium::UI::Display::Components::PhlexiRender, :phlexi_render, **, &)
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
# Themed as :string so a formatted value looks identical to any other
|
|
31
|
+
# string field (same wrapper + text-lg), just with the value passed
|
|
32
|
+
# through the `formatter:` proc.
|
|
33
|
+
def formatted_value_tag(**, &)
|
|
34
|
+
create_component(Plutonium::UI::Display::Components::FormattedValue, :string, **, &)
|
|
35
|
+
end
|
|
36
|
+
|
|
29
37
|
def boolean_tag(**, &)
|
|
30
38
|
create_component(Plutonium::UI::Display::Components::Boolean, :boolean, **, &)
|
|
31
39
|
end
|
|
@@ -53,8 +61,12 @@ module Plutonium
|
|
|
53
61
|
|
|
54
62
|
private
|
|
55
63
|
|
|
64
|
+
# A card (the shared {Plutonium::UI::Block} primitive) around the field
|
|
65
|
+
# grid. Block owns what a card IS — surface, border, radius, shadow —
|
|
66
|
+
# so callers just ask for a fields_wrapper and cannot end up with a
|
|
67
|
+
# card that disagrees with the ones beside it.
|
|
56
68
|
def fields_wrapper(&)
|
|
57
|
-
|
|
69
|
+
render Plutonium::UI::Block.new(class: themed(:fields_wrapper)) {
|
|
58
70
|
div(class: themed(:fields_inner)) {
|
|
59
71
|
yield
|
|
60
72
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Display
|
|
6
|
+
module Components
|
|
7
|
+
# Renders a value transformed by a user-supplied `formatter:` proc, themed
|
|
8
|
+
# like a plain string. `formatter:` is a general column/display option, but
|
|
9
|
+
# only the string component ever consumed it — handed to a typed component
|
|
10
|
+
# (boolean pill, currency, badge…) the Proc leaked into the element's HTML
|
|
11
|
+
# attributes and Phlex rejected it. The resource/table render layers route
|
|
12
|
+
# every formatter-bearing field here instead.
|
|
13
|
+
#
|
|
14
|
+
# Unlike Phlexi's String component — which stringifies the value *before*
|
|
15
|
+
# the formatter runs (so a boolean `false` would arrive as the truthy
|
|
16
|
+
# string "false") — this keeps the raw value, matching the documented
|
|
17
|
+
# contract that a formatter "receives just the value".
|
|
18
|
+
class FormattedValue < Phlexi::Display::Components::String
|
|
19
|
+
# Keep the raw value so the formatter sees a real boolean / number /
|
|
20
|
+
# object rather than its `to_s`.
|
|
21
|
+
def normalize_value(value) = value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Display
|
|
6
|
+
module Components
|
|
7
|
+
# A show page's declared `display_layout` section. All chrome lives in
|
|
8
|
+
# the shared {Plutonium::UI::Component::Section} so a show-page section
|
|
9
|
+
# and a form section cannot drift apart; the display contributes only
|
|
10
|
+
# its grid class (the themed :section_grid) and the block that renders
|
|
11
|
+
# the fields.
|
|
12
|
+
class Section < Plutonium::UI::Component::Section
|
|
13
|
+
def self.theme_class = Plutonium::UI::Display::Theme
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -35,27 +35,143 @@ module Plutonium
|
|
|
35
35
|
present_associations? && resource_associations.present?
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Stacked in `sections_wrapper` — the hooks and the fields card each
|
|
39
|
+
# own their own card with no margin, so an override that renders one
|
|
40
|
+
# (e.g. Async::RunDefinition's progress panel) would otherwise butt flush
|
|
41
|
+
# against the fields card.
|
|
38
42
|
def render_fields
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
div(class: themed(:sections_wrapper)) do
|
|
44
|
+
render_before_fields
|
|
45
|
+
render_default_fields
|
|
46
|
+
render_after_fields
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Hooks for content that belongs to the Details tab alone. render_fields
|
|
51
|
+
# is the tab body when associations exist, and the whole display when
|
|
52
|
+
# they don't, so these fire in the Details context either way and never
|
|
53
|
+
# in an association tab. The page-level render_before_content /
|
|
54
|
+
# render_after_content hooks sit outside the tablist and would show
|
|
55
|
+
# regardless of the active tab.
|
|
56
|
+
def render_before_fields
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def render_after_fields
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def render_default_fields
|
|
63
|
+
# Inside a kanban card's modal we render a compact detail: no metadata
|
|
64
|
+
# at all. render_main_field_card already subtracts metadata_fields, so
|
|
65
|
+
# those fields stay hidden rather than folding into the main card.
|
|
66
|
+
return render_main_field_card if metadata_fields.none? || in_kanban_modal?
|
|
67
|
+
|
|
68
|
+
# In a (non-kanban) modal the metadata stacks BELOW the details
|
|
69
|
+
# instead of beside them. The rail is a fixed 320px column and its
|
|
70
|
+
# `lg:` breakpoint is a viewport query, so it would split inside the
|
|
71
|
+
# dialog no matter how narrow the dialog is — leaving the main column
|
|
72
|
+
# crushed against it. Stacking sidesteps that entirely, and means the
|
|
73
|
+
# dialog does not have to grow just to hold a rail.
|
|
74
|
+
if in_modal?
|
|
75
|
+
# A stacking container, not two bare siblings: the cards carry no
|
|
76
|
+
# margin of their own, so without it they butt straight up against
|
|
77
|
+
# each other with no seam between the details and the metadata.
|
|
78
|
+
return div(class: themed(:sections_wrapper)) {
|
|
79
|
+
render_main_field_card
|
|
80
|
+
render_metadata_panel
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start") do
|
|
85
|
+
div { render_main_field_card }
|
|
86
|
+
aside { render_metadata_panel }
|
|
46
87
|
end
|
|
47
88
|
end
|
|
48
89
|
|
|
49
90
|
def render_main_field_card
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
# Skip fields claimed by the metadata panel — rendering them in both
|
|
92
|
+
# places duplicates information.
|
|
93
|
+
fields = resource_fields - metadata_fields
|
|
94
|
+
sections = resolve_display_layout(fields)
|
|
95
|
+
|
|
96
|
+
# Unsectioned: one card holding one grid. fields_wrapper is itself a
|
|
97
|
+
# Block now, so this is the same card primitive the sections and the
|
|
98
|
+
# metadata panel use.
|
|
99
|
+
if sections.nil?
|
|
100
|
+
return fields_wrapper do
|
|
101
|
+
fields.each { |name| render_resource_field name }
|
|
57
102
|
end
|
|
58
103
|
end
|
|
104
|
+
|
|
105
|
+
# Sectioned: each section is its own card, so there is deliberately
|
|
106
|
+
# no Block/card wrapper here — one would nest cards inside a card.
|
|
107
|
+
div(class: themed(:sections_wrapper)) do
|
|
108
|
+
sections.each { |rs| render_display_section(rs) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Resolve the whole display layout for THIS render: drop
|
|
113
|
+
# condition-hidden sections and sections left with no fields (e.g.
|
|
114
|
+
# every declared field was filtered out by the permitted set).
|
|
115
|
+
# Returns nil when no display_layout is declared (caller falls back
|
|
116
|
+
# to a single grid).
|
|
117
|
+
def resolve_display_layout(fields)
|
|
118
|
+
sections = resource_definition.resolve_display_sections(fields)
|
|
119
|
+
return nil if sections.nil?
|
|
120
|
+
|
|
121
|
+
sections.filter_map do |resolved|
|
|
122
|
+
section = resolved.section
|
|
123
|
+
condition = section.condition
|
|
124
|
+
next if condition && !instance_exec(&condition)
|
|
125
|
+
next if resolved.fields.empty?
|
|
126
|
+
|
|
127
|
+
Plutonium::Definition::FormLayout::ResolvedSection.new(
|
|
128
|
+
section: Plutonium::Definition::FormLayout::Section.new(
|
|
129
|
+
key: section.key,
|
|
130
|
+
fields: section.fields,
|
|
131
|
+
options: resolve_section_option_procs(section.options).freeze
|
|
132
|
+
),
|
|
133
|
+
fields: resolved.fields
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Resolve proc-valued section options for THIS render, by the same
|
|
139
|
+
# arity rule the form uses (Form::Resource#resolve_option_procs): a
|
|
140
|
+
# zero-arity proc is called plainly and keeps its own binding, while a
|
|
141
|
+
# one-arity proc is handed this display for `object`/`params`/helpers.
|
|
142
|
+
#
|
|
143
|
+
# Without this a `collapsed: -> { ... }` would reach the component as
|
|
144
|
+
# the Proc itself, and `collapsed?` — a plain `!!` — would be true no
|
|
145
|
+
# matter what the proc returns, silently pinning the section shut.
|
|
146
|
+
#
|
|
147
|
+
# `condition:` is excluded for the same reason it is on the form: it
|
|
148
|
+
# asks "should this render here, now?", which is resolved separately
|
|
149
|
+
# and against this display, not turned into a value up front.
|
|
150
|
+
def resolve_section_option_procs(options)
|
|
151
|
+
return options if options.blank?
|
|
152
|
+
|
|
153
|
+
options.to_h do |key, value|
|
|
154
|
+
resolvable = key != :condition && value.is_a?(Proc)
|
|
155
|
+
next [key, value] unless resolvable
|
|
156
|
+
|
|
157
|
+
[key, value.arity.zero? ? value.call : value.call(self)]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Pure presentation — the section is already resolved (visible) by
|
|
162
|
+
# resolve_display_layout.
|
|
163
|
+
# Every section renders into the SAME responsive grid (themed
|
|
164
|
+
# :section_grid, which matches the unsectioned path's :fields_inner
|
|
165
|
+
# minus the padding its wrapper now owns). display_layout groups
|
|
166
|
+
# fields; it does not resize them — that stays a per-field concern,
|
|
167
|
+
# via `display :x, wrapper: {class: "col-span-2"}`, which works
|
|
168
|
+
# identically inside a section and outside one.
|
|
169
|
+
def render_display_section(resolved)
|
|
170
|
+
render Plutonium::UI::Display::Components::Section.new(
|
|
171
|
+
resolved, grid_class: themed(:section_grid)
|
|
172
|
+
) do
|
|
173
|
+
resolved.fields.each { |name| render_resource_field name }
|
|
174
|
+
end
|
|
59
175
|
end
|
|
60
176
|
|
|
61
177
|
# Renders the declared metadata fields as a vertical stack beside
|
|
@@ -64,6 +180,9 @@ module Plutonium
|
|
|
64
180
|
# only difference from the main card is the wrapper — a single
|
|
65
181
|
# column of fields instead of the form's multi-column grid.
|
|
66
182
|
def render_metadata_panel
|
|
183
|
+
# Same card primitive as the details card(s) beside it, so the two
|
|
184
|
+
# sit on one surface — this panel used to be a borderless,
|
|
185
|
+
# heavier-shadowed Block and read as a different kind of thing.
|
|
67
186
|
Block do
|
|
68
187
|
div(class: "pu-card-body flex flex-col gap-6") do
|
|
69
188
|
metadata_fields.each { |name| render_resource_field(name) }
|
|
@@ -189,14 +308,14 @@ module Plutonium
|
|
|
189
308
|
field_options = field_options.merge(field_level_options)
|
|
190
309
|
|
|
191
310
|
tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
311
|
+
|
|
312
|
+
# A `formatter:` produces the display string itself, so render it
|
|
313
|
+
# through the formatted-value component regardless of the field's
|
|
314
|
+
# type. Otherwise the Proc would be handed to a typed component
|
|
315
|
+
# (boolean pill, currency…) and leak into its HTML attributes.
|
|
316
|
+
tag = :formatted_value if tag_attributes.key?(:formatter)
|
|
317
|
+
|
|
318
|
+
tag_block = display_definition[:block] || ->(f) { f.component_for(tag, **tag_attributes) }
|
|
200
319
|
|
|
201
320
|
wrapper_options = display_options[:wrapper] || {}
|
|
202
321
|
|