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,9 +8,28 @@ module Plutonium
|
|
|
8
8
|
super.merge({
|
|
9
9
|
base: "",
|
|
10
10
|
value_wrapper: "max-h-[300px] overflow-y-auto",
|
|
11
|
-
fields_wrapper
|
|
11
|
+
# Merged into the fields_wrapper's Block, which supplies `pu-card`
|
|
12
|
+
# itself — this is only for anything a caller wants to add on top.
|
|
13
|
+
fields_wrapper: nil,
|
|
12
14
|
fields_inner: "pu-card-body grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
|
|
13
15
|
|
|
16
|
+
# display_layout sectioning. `sections_wrapper` replaces
|
|
17
|
+
# `fields_inner` as the card's inner padding box when a layout is
|
|
18
|
+
# declared (it stacks sections instead of fields directly), and
|
|
19
|
+
# each section's field grid uses `section_grid` — the same grid as
|
|
20
|
+
# `fields_inner` without the padding, which the wrapper now owns.
|
|
21
|
+
# Split into their own keys so overriding one path doesn't
|
|
22
|
+
# silently reshape the other.
|
|
23
|
+
# Sections are their OWN cards (see Component::Section), so this
|
|
24
|
+
# only has to stack them — no card, no padding of its own.
|
|
25
|
+
sections_wrapper: "space-y-4",
|
|
26
|
+
section_grid: "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
|
|
27
|
+
|
|
28
|
+
# Section chrome (heading, accent, divider, collapsible caret).
|
|
29
|
+
# Shared defaults with the form so both read the same; override any
|
|
30
|
+
# key here to restyle show-page sections alone.
|
|
31
|
+
**Plutonium::UI::Component::Section::DEFAULT_THEME,
|
|
32
|
+
|
|
14
33
|
# Labels and descriptions
|
|
15
34
|
label: "text-sm font-semibold uppercase tracking-wide text-[var(--pu-text-muted)] mb-2",
|
|
16
35
|
description: "text-sm text-[var(--pu-text-subtle)]",
|
|
@@ -63,7 +63,7 @@ module Plutonium
|
|
|
63
63
|
|
|
64
64
|
def render_menu
|
|
65
65
|
div(
|
|
66
|
-
class: "hidden absolute right-0 top-full z-50 mt-1 w-48 origin-top-right bg-[var(--pu-surface)] " \
|
|
66
|
+
class: "hidden absolute right-0 top-full z-50 mt-1 w-max min-w-48 max-w-[min(20rem,90vw)] origin-top-right bg-[var(--pu-surface)] " \
|
|
67
67
|
"border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)] overflow-hidden",
|
|
68
68
|
style: "box-shadow: var(--pu-shadow-lg)",
|
|
69
69
|
data: {resource_drop_down_target: "menu"}
|
|
@@ -9,6 +9,7 @@ module Plutonium
|
|
|
9
9
|
class Builder < Builder
|
|
10
10
|
include Phlexi::Field::Common::Tokens
|
|
11
11
|
include Plutonium::UI::Form::Options::InferredTypes
|
|
12
|
+
include Plutonium::UI::Component::ResolvesTags
|
|
12
13
|
|
|
13
14
|
# Consume `:as` here so it doesn't land in Phlexi's `@options` —
|
|
14
15
|
# `:as` is a Plutonium-internal concept (it picks the tag method),
|
|
@@ -74,16 +75,16 @@ module Plutonium
|
|
|
74
75
|
end
|
|
75
76
|
alias_method :phone_tag, :int_tel_input_tag
|
|
76
77
|
|
|
77
|
-
# The `as:` values that render through the Uppy file-upload component —
|
|
78
|
-
# the single source of truth, also consulted by
|
|
79
|
-
# {Plutonium::Wizard::Attachments.field?} so a wizard can detect an
|
|
80
|
-
# attachment field model-free without re-listing the aliases.
|
|
81
|
-
FILE_INPUT_TYPES = %i[uppy file attachment].freeze
|
|
82
|
-
|
|
83
78
|
def uppy_tag(**, &)
|
|
84
79
|
create_component(Components::Uppy, :uppy, **, &)
|
|
85
80
|
end
|
|
86
|
-
|
|
81
|
+
# Every file alias renders through Uppy. The list itself lives with the
|
|
82
|
+
# other `as:` semantics in {Plutonium::Definition::InputAliases}, which
|
|
83
|
+
# is also where non-view callers (controller, wizard, generators) ask
|
|
84
|
+
# whether an `as:` is an attachment — so the two never drift.
|
|
85
|
+
(Plutonium::Definition::InputAliases::FILE_INPUT_TYPES - [:uppy]).each do |name|
|
|
86
|
+
alias_method :"#{name}_tag", :uppy_tag
|
|
87
|
+
end
|
|
87
88
|
|
|
88
89
|
def key_value_store_tag(**, &)
|
|
89
90
|
create_component(Components::KeyValueStore, :key_value_store, **, &)
|
|
@@ -35,7 +35,7 @@ module Plutonium
|
|
|
35
35
|
# `Plutonium.configuration.default_phone_country`.
|
|
36
36
|
def build_intl_options
|
|
37
37
|
options = {}
|
|
38
|
-
if (country = attributes.delete(:initial_country) || Plutonium.configuration.
|
|
38
|
+
if (country = attributes.delete(:initial_country) || Plutonium.configuration.normalized_default_phone_country)
|
|
39
39
|
options[:initialCountry] = country
|
|
40
40
|
end
|
|
41
41
|
if (extra = attributes.delete(:intl_options))
|
|
@@ -4,68 +4,13 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Form
|
|
6
6
|
module Components
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
class
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
SECTION_CLASS = "space-y-5 pt-7 first:pt-0"
|
|
17
|
-
# A short primary accent rule to the left of the heading — anchors the
|
|
18
|
-
# section and adds a touch of brand. Shared by the grouped <div> header
|
|
19
|
-
# and the collapsible <summary> so both read the same.
|
|
20
|
-
ACCENT_CLASS = "border-l-[3px] border-primary-500 pl-3.5"
|
|
21
|
-
HEADING_CLASS = "text-base font-semibold tracking-tight text-[var(--pu-text)]"
|
|
22
|
-
SUMMARY_CLASS = "#{HEADING_CLASS} #{ACCENT_CLASS} cursor-pointer select-none"
|
|
23
|
-
# font-normal resets the semibold inherited from a <summary> parent.
|
|
24
|
-
DESCRIPTION_CLASS = "mt-1 text-sm font-normal text-[var(--pu-text-muted)]"
|
|
25
|
-
|
|
26
|
-
def view_template(&fields_block)
|
|
27
|
-
if @section.collapsible?
|
|
28
|
-
details(open: !@section.collapsed?, class: SECTION_CLASS) do
|
|
29
|
-
# <summary> must be the first child of <details> and can't be
|
|
30
|
-
# wrapped, so the title text and its description both live inside
|
|
31
|
-
# it — keeping the description hugging the title under one accent.
|
|
32
|
-
summary(class: SUMMARY_CLASS) do
|
|
33
|
-
plain heading_text
|
|
34
|
-
describe
|
|
35
|
-
end
|
|
36
|
-
grid(&fields_block)
|
|
37
|
-
end
|
|
38
|
-
else
|
|
39
|
-
div(class: SECTION_CLASS) do
|
|
40
|
-
header_block
|
|
41
|
-
grid(&fields_block)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
# Title + description grouped under one accented header so the
|
|
49
|
-
# description hugs the heading (mt-1) instead of inheriting the
|
|
50
|
-
# section's larger vertical rhythm.
|
|
51
|
-
def header_block
|
|
52
|
-
return if @section.ungrouped? && @section.options[:label].nil?
|
|
53
|
-
div(class: ACCENT_CLASS) do
|
|
54
|
-
h3(class: HEADING_CLASS) { heading_text }
|
|
55
|
-
describe
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def heading_text = @section.label
|
|
60
|
-
|
|
61
|
-
def describe
|
|
62
|
-
return unless @section.description
|
|
63
|
-
p(class: DESCRIPTION_CLASS) { @section.description }
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def grid(&fields_block)
|
|
67
|
-
div(class: @grid_class, &fields_block)
|
|
68
|
-
end
|
|
7
|
+
# A form's declared `form_layout` section. All chrome lives in the
|
|
8
|
+
# shared {Plutonium::UI::Component::Section} so a form section and a
|
|
9
|
+
# show-page section cannot drift apart; the form contributes only its
|
|
10
|
+
# own grid class (via `columns:`) and the block that renders the
|
|
11
|
+
# fields (the form supplies render_resource_field).
|
|
12
|
+
class Section < Plutonium::UI::Component::Section
|
|
13
|
+
def self.theme_class = Plutonium::UI::Form::Theme
|
|
69
14
|
end
|
|
70
15
|
end
|
|
71
16
|
end
|
|
@@ -54,9 +54,20 @@ module Plutonium
|
|
|
54
54
|
|
|
55
55
|
private
|
|
56
56
|
|
|
57
|
+
# Previews for what is ALREADY attached.
|
|
58
|
+
#
|
|
59
|
+
# A model-backed field holds attachment objects, which answer +url+. An
|
|
60
|
+
# interaction's field does not: between submit and re-render it holds
|
|
61
|
+
# whatever the user posted — an ActionDispatch::Http::UploadedFile, or
|
|
62
|
+
# the token staging replaced it with. Neither answers +url+, and
|
|
63
|
+
# calling it took the whole form down with a NoMethodError on any
|
|
64
|
+
# validation failure, including one on an unrelated attribute.
|
|
65
|
+
#
|
|
66
|
+
# A value that cannot answer +url+ is not an existing attachment, so
|
|
67
|
+
# there is nothing here to preview for it.
|
|
57
68
|
def render_existing_attachments
|
|
58
69
|
Array(field.value).each do |attachment|
|
|
59
|
-
next unless attachment
|
|
70
|
+
next unless attachment.respond_to?(:url) && attachment.url.present?
|
|
60
71
|
|
|
61
72
|
render_attachment_preview(attachment)
|
|
62
73
|
end
|
|
@@ -23,11 +23,17 @@ module Plutonium
|
|
|
23
23
|
class NestedFieldContext
|
|
24
24
|
attr_reader :name, :definition, :options, :permitted_fields
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
# @param declared_options [Hash] the nested input's own options, already
|
|
27
|
+
# proc-resolved by the form (the context has no form to resolve
|
|
28
|
+
# against). Everything merged in below — nested-attribute macro,
|
|
29
|
+
# limits — is derived from the model, never authored, so this is the
|
|
30
|
+
# only half that can carry a proc.
|
|
31
|
+
def initialize(name:, definition:, resource_class:, resource_definition:, object_class:, declared_options:)
|
|
27
32
|
@name = name
|
|
28
33
|
@definition = definition
|
|
29
34
|
@resource_definition = resource_definition
|
|
30
35
|
@resource_class = resource_class
|
|
36
|
+
@declared_options = declared_options
|
|
31
37
|
@options = build_options
|
|
32
38
|
@permitted_fields = build_permitted_fields
|
|
33
39
|
@object_class = object_class
|
|
@@ -52,7 +58,7 @@ module Plutonium
|
|
|
52
58
|
private
|
|
53
59
|
|
|
54
60
|
def build_options
|
|
55
|
-
options = @
|
|
61
|
+
options = @declared_options.dup
|
|
56
62
|
merge_nested_fields_options(options)
|
|
57
63
|
set_nested_fields_limits(options)
|
|
58
64
|
options
|
|
@@ -97,12 +103,19 @@ module Plutonium
|
|
|
97
103
|
return
|
|
98
104
|
end
|
|
99
105
|
|
|
106
|
+
# Resolved here rather than in the context: `condition:` above needs the
|
|
107
|
+
# raw proc (resolve_option_procs deliberately leaves it alone, but it is
|
|
108
|
+
# also evaluated before this point), and the context has no form to
|
|
109
|
+
# resolve the rest against.
|
|
110
|
+
declared_options = resolve_option_procs(nested_input_definition[:options] || {})
|
|
111
|
+
|
|
100
112
|
context = NestedFieldContext.new(
|
|
101
113
|
name: name,
|
|
102
114
|
definition: build_nested_fields_definition(name),
|
|
103
115
|
resource_class: resource_class,
|
|
104
116
|
resource_definition: resource_definition,
|
|
105
|
-
object_class:
|
|
117
|
+
object_class: declared_options[:object_class],
|
|
118
|
+
declared_options: declared_options
|
|
106
119
|
)
|
|
107
120
|
|
|
108
121
|
render_nested_field_container(context) do
|
|
@@ -15,7 +15,11 @@ module Plutonium
|
|
|
15
15
|
|
|
16
16
|
def render_structured_input(name)
|
|
17
17
|
entry = resource_definition.defined_structured_inputs[name]
|
|
18
|
-
|
|
18
|
+
# Same proc resolution the inner fields already get (they render
|
|
19
|
+
# through render_simple_resource_field on this same form), so
|
|
20
|
+
# `repeat:`/`fields:` can depend on the request — or, on a wizard
|
|
21
|
+
# step, on the run: the receiver rule is the form's, not ours.
|
|
22
|
+
options = resolve_option_procs(entry[:options] || {})
|
|
19
23
|
definition = structured_input_fields_definition(entry)
|
|
20
24
|
fields = options[:fields] || definition.defined_inputs.keys
|
|
21
25
|
repeat = options[:repeat]
|
|
@@ -102,6 +102,7 @@ module Plutonium
|
|
|
102
102
|
button(
|
|
103
103
|
type: "button",
|
|
104
104
|
class: "pu-btn pu-btn-secondary px-4 py-3 text-base",
|
|
105
|
+
aria: {expanded: "false"},
|
|
105
106
|
data: {resource_drop_down_target: "trigger"}
|
|
106
107
|
) do
|
|
107
108
|
render Phlex::TablerIcons::Filter.new(class: "w-4 h-4 inline-block align-text-bottom")
|
|
@@ -186,10 +187,7 @@ module Plutonium
|
|
|
186
187
|
tag = input_options[:as] || field_options[:as]
|
|
187
188
|
tag_attributes = input_options.except(:wrapper, :as)
|
|
188
189
|
|
|
189
|
-
tag_block = input_definition[:block] || ->(f) {
|
|
190
|
-
tag ||= f.inferred_field_component
|
|
191
|
-
f.send(:"#{tag}_tag", **tag_attributes, class: "w-full")
|
|
192
|
-
}
|
|
190
|
+
tag_block = input_definition[:block] || ->(f) { f.component_for(tag, **tag_attributes, class: "w-full") }
|
|
193
191
|
|
|
194
192
|
field_options = field_options.except(:as)
|
|
195
193
|
|
|
@@ -49,8 +49,20 @@ module Plutonium
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# The form is normally a card (theme :base). When a form_layout is
|
|
53
|
+
# declared each section becomes a card of its own, so the form must
|
|
54
|
+
# stop being one — otherwise every section is a card nested inside a
|
|
55
|
+
# card, doubling borders and padding. The sections' own container
|
|
56
|
+
# supplies the spacing that :base's padding used to.
|
|
57
|
+
#
|
|
58
|
+
# Width comes from configuration rather than the theme (`page_width` /
|
|
59
|
+
# `form_width` on the definition), so it is appended here. A modal form
|
|
60
|
+
# gets none of this: the dialog already sets its own width.
|
|
52
61
|
def form_class
|
|
53
|
-
|
|
62
|
+
return "flex-1 flex flex-col min-h-0" if in_modal?
|
|
63
|
+
|
|
64
|
+
base = resource_definition.defined_form_layout ? themed(:sectioned_base, nil) : super
|
|
65
|
+
tokens(base, resource_definition.form_width_classes)
|
|
54
66
|
end
|
|
55
67
|
|
|
56
68
|
private
|
|
@@ -104,7 +116,15 @@ module Plutonium
|
|
|
104
116
|
resource_fields.each { |name| render_resource_field name }
|
|
105
117
|
}
|
|
106
118
|
else
|
|
107
|
-
|
|
119
|
+
# Sections live in their own stacking container rather than being
|
|
120
|
+
# emitted straight into the form body. They are cards in their own
|
|
121
|
+
# right, and the container is what spaces them; without it they
|
|
122
|
+
# would also share a parent with the errors block, so any
|
|
123
|
+
# `:first-child` styling in the section chrome would silently
|
|
124
|
+
# target the wrong element.
|
|
125
|
+
div(class: themed(:sections_wrapper, nil)) do
|
|
126
|
+
sections.each { |rs| render_form_section(rs) }
|
|
127
|
+
end
|
|
108
128
|
end
|
|
109
129
|
end
|
|
110
130
|
|
|
@@ -132,10 +152,11 @@ module Plutonium
|
|
|
132
152
|
# section's own `condition:`.
|
|
133
153
|
next if resolved.fields.empty?
|
|
134
154
|
|
|
135
|
-
# `columns` stays a validated literal
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
# `columns` stays a validated literal (the Builder rejects a non-Integer
|
|
156
|
+
# at declaration); `label`/`description`/`collapsible`/`collapsed` may be
|
|
157
|
+
# procs, resolved by the SAME arity rule as a field/input option so
|
|
158
|
+
# `-> { … }` means one thing across the whole form DSL.
|
|
159
|
+
options = resolve_option_procs(section.options)
|
|
139
160
|
Plutonium::Definition::FormLayout::ResolvedSection.new(
|
|
140
161
|
section: Plutonium::Definition::FormLayout::Section.new(
|
|
141
162
|
key: section.key, fields: section.fields, options: options.freeze
|
|
@@ -289,6 +310,9 @@ module Plutonium
|
|
|
289
310
|
input_definition = definition.defined_inputs[name] || {}
|
|
290
311
|
input_options = input_definition[:options] || {}
|
|
291
312
|
|
|
313
|
+
field_options = resolve_option_procs(field_options)
|
|
314
|
+
input_options = resolve_option_procs(input_options)
|
|
315
|
+
|
|
292
316
|
tag = input_options[:as] || field_options[:as]
|
|
293
317
|
|
|
294
318
|
# Extract field-level options from input_options and merge into field_options
|
|
@@ -298,20 +322,28 @@ module Plutonium
|
|
|
298
322
|
field_level_options = input_options.slice(*field_level_keys)
|
|
299
323
|
field_options = field_options.merge(field_level_options)
|
|
300
324
|
|
|
301
|
-
|
|
325
|
+
# Staging options are stripped here as well as in Wizard::StepAdapter,
|
|
326
|
+
# because an interaction's inputs reach this method directly. A
|
|
327
|
+
# Class-valued `uploader:` is not an HTML attribute and Phlex refuses
|
|
328
|
+
# it, so leaving it in raises rather than rendering.
|
|
329
|
+
tag_attributes = input_options.except(
|
|
330
|
+
:wrapper, :as, :pre_submit, :condition, *field_level_keys,
|
|
331
|
+
*Plutonium::Attachments::STAGING_ONLY_INPUT_OPTIONS
|
|
332
|
+
)
|
|
302
333
|
if input_options[:pre_submit]
|
|
303
334
|
tag_attributes["data-action"] = "change->form#preSubmit"
|
|
304
335
|
end
|
|
305
|
-
tag_block = input_definition[:block] || ->(f)
|
|
306
|
-
tag ||= f.inferred_field_component
|
|
307
|
-
if tag.is_a?(Class)
|
|
308
|
-
f.send :create_component, tag, tag.name.demodulize.underscore.sub(/component$/, "").to_sym
|
|
309
|
-
else
|
|
310
|
-
f.send(:"#{tag}_tag", **tag_attributes)
|
|
311
|
-
end
|
|
312
|
-
end
|
|
336
|
+
tag_block = input_definition[:block] || ->(f) { f.component_for(tag, **tag_attributes) }
|
|
313
337
|
|
|
314
338
|
# Keep `:as` so the Builder can detect hidden fields via `options[:as]`.
|
|
339
|
+
# `as:` may be declared on either `field` or `input`, and the tag
|
|
340
|
+
# lookup above already honours both. Carry the resolved value back
|
|
341
|
+
# into field_options so the Builder sees it too: without this,
|
|
342
|
+
# `input :x, as: :hidden` renders a hidden input inside a fully
|
|
343
|
+
# chromed wrapper, label and all, while `field :x, as: :hidden`
|
|
344
|
+
# renders correctly. The Builder consumes `:as` before Phlexi sees
|
|
345
|
+
# the options, so this cannot leak into a Phlexi field option.
|
|
346
|
+
field_options = field_options.merge(as: tag) if tag
|
|
315
347
|
field_options = field_options.except(:condition)
|
|
316
348
|
|
|
317
349
|
condition = input_options[:condition] || field_options[:condition]
|
|
@@ -346,6 +378,51 @@ module Plutonium
|
|
|
346
378
|
end
|
|
347
379
|
end
|
|
348
380
|
|
|
381
|
+
# Resolve proc-valued options for THIS render, so any option can vary per
|
|
382
|
+
# request rather than being frozen when the class loads. One rule for the
|
|
383
|
+
# whole form DSL: `field`/`input`, `section`/`ungrouped`, `structured_input`
|
|
384
|
+
# and nested inputs all route through here.
|
|
385
|
+
#
|
|
386
|
+
# Arity says whether you want the form, NOT which `self` you get:
|
|
387
|
+
#
|
|
388
|
+
# -> { … } a plain call — means what it reads like where it was
|
|
389
|
+
# written, closure and all
|
|
390
|
+
# ->(form) { … } handed this form, for `object`/`params`/helpers
|
|
391
|
+
#
|
|
392
|
+
# This is `choices:`'s rule, adopted rather than replaced. Phlexi has
|
|
393
|
+
# always resolved a `choices:` proc with a plain `.call`, so it is both
|
|
394
|
+
# the oldest of these semantics and the only one that predates Plutonium's
|
|
395
|
+
# own option layer — and it is the unsurprising one: nothing silently
|
|
396
|
+
# rebinds `self` out from under a lambda. `section :x, collapsed: -> { … }`
|
|
397
|
+
# (v0.60) followed the other convention and was migrated to `->(form)` to
|
|
398
|
+
# meet it here.
|
|
399
|
+
#
|
|
400
|
+
# Form::Wizard overrides the zero-arity case, because a step block is
|
|
401
|
+
# instance_exec'd against a throwaway recorder — the closure there holds
|
|
402
|
+
# nothing, so the wizard takes its place.
|
|
403
|
+
#
|
|
404
|
+
# `condition:` is excluded because it is a different kind of thing, not
|
|
405
|
+
# because it is an awkward leftover. An option asks "what value should
|
|
406
|
+
# this have?" and may not care about the render; `condition:` asks
|
|
407
|
+
# "should this render here, now?", which is a question about the render
|
|
408
|
+
# context by definition. So it is evaluated separately, always AGAINST
|
|
409
|
+
# its context — and that context is not always a form (a `column`'s is the
|
|
410
|
+
# table, a step's is the wizard, an action's is a ConditionContext), which
|
|
411
|
+
# is why it cannot take a `form` argument the way an option does.
|
|
412
|
+
# Resolving it here would also collapse it to a boolean before the render
|
|
413
|
+
# site that owns it gets to ask.
|
|
414
|
+
def resolve_option_procs(options)
|
|
415
|
+
return options if options.blank?
|
|
416
|
+
|
|
417
|
+
options.to_h do |key, value|
|
|
418
|
+
[key, resolvable_proc?(key, value) ? call_option_proc(value) : value]
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def resolvable_proc?(key, value) = key != :condition && value.is_a?(Proc)
|
|
423
|
+
|
|
424
|
+
def call_option_proc(value) = value.arity.zero? ? value.call : value.call(self)
|
|
425
|
+
|
|
349
426
|
def when_permitted(name, &)
|
|
350
427
|
return unless resource_fields.include? name
|
|
351
428
|
|
|
@@ -7,8 +7,25 @@ module Plutonium
|
|
|
7
7
|
def self.theme
|
|
8
8
|
super.merge({
|
|
9
9
|
# Form structure
|
|
10
|
+
# Width is NOT baked in here — it is configuration, not theme
|
|
11
|
+
# (`page_width` / `form_width` on the definition, defaulting to
|
|
12
|
+
# `Plutonium.configuration.default_page_width`), so Form::Resource
|
|
13
|
+
# appends it. Overriding these keys therefore restyles the form
|
|
14
|
+
# without silently pinning its width.
|
|
10
15
|
base: "pu-card my-4 p-8 space-y-8",
|
|
16
|
+
# :base for a form whose sections are cards — no card, no padding
|
|
17
|
+
# of its own, since the section cards provide both.
|
|
18
|
+
sectioned_base: "my-4 space-y-4",
|
|
11
19
|
fields_wrapper: "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-6 grid-flow-row-dense",
|
|
20
|
+
|
|
21
|
+
# Sections are their OWN cards (see Component::Section), so this
|
|
22
|
+
# only has to stack them.
|
|
23
|
+
sections_wrapper: "space-y-4",
|
|
24
|
+
|
|
25
|
+
# Section chrome (heading, accent, divider, collapsible caret).
|
|
26
|
+
# Shared defaults with the show page so both read the same;
|
|
27
|
+
# override any key here to restyle form sections alone.
|
|
28
|
+
**Plutonium::UI::Component::Section::DEFAULT_THEME,
|
|
12
29
|
actions_wrapper: "flex justify-end gap-3 pt-4 border-t border-[var(--pu-border-muted)]",
|
|
13
30
|
wrapper: nil,
|
|
14
31
|
inner_wrapper: "w-full",
|
|
@@ -16,8 +16,13 @@ module Plutonium
|
|
|
16
16
|
# @param action [String] the current step's POST URL.
|
|
17
17
|
# @param fields [Array<Symbol>] the step's renderable field names
|
|
18
18
|
# (scalar attributes + structured inputs).
|
|
19
|
-
|
|
19
|
+
# @param wizard [Plutonium::Wizard::Base] the run being rendered. Exposed
|
|
20
|
+
# as `form.wizard` so a step's proc-valued option can reach the run —
|
|
21
|
+
# see {#wizard}. Required: it is read during render, so defaulting it to
|
|
22
|
+
# nil would only defer the failure to a NoMethodError inside a proc.
|
|
23
|
+
def initialize(step:, data:, action:, fields:, wizard:, **options, &)
|
|
20
24
|
@step = step
|
|
25
|
+
@wizard = wizard
|
|
21
26
|
options[:key] = :wizard
|
|
22
27
|
options[:as] = :wizard
|
|
23
28
|
options[:action] = action
|
|
@@ -27,6 +32,25 @@ module Plutonium
|
|
|
27
32
|
super(data, **options, &)
|
|
28
33
|
end
|
|
29
34
|
|
|
35
|
+
# The run being rendered. Public because a step's proc-valued option is
|
|
36
|
+
# how an option depends on the run, and reaching the run is the whole
|
|
37
|
+
# point:
|
|
38
|
+
#
|
|
39
|
+
# input :tier, as: :select,
|
|
40
|
+
# choices: ->(form) { form.wizard.anchor.available_tiers }
|
|
41
|
+
#
|
|
42
|
+
# Deliberately NOT reached by rebinding a zero-arity proc to the wizard.
|
|
43
|
+
# A step block is instance_exec'd against a FieldCapture at class load,
|
|
44
|
+
# so its closure holds nothing useful — but that is equally true of a
|
|
45
|
+
# `form_layout` block and its Builder, and the answer there is to ask for
|
|
46
|
+
# the receiver rather than have it swapped in silently. Same answer here:
|
|
47
|
+
# `-> { … }` means what it reads like wherever it is written, and an
|
|
48
|
+
# `input` line moved between a definition and a step keeps its meaning.
|
|
49
|
+
#
|
|
50
|
+
# `form.object`, alongside this, is the step's staged data.
|
|
51
|
+
# @return [Plutonium::Wizard::Base]
|
|
52
|
+
attr_reader :wizard
|
|
53
|
+
|
|
30
54
|
private
|
|
31
55
|
|
|
32
56
|
attr_reader :step
|
|
@@ -10,11 +10,22 @@ module Plutonium
|
|
|
10
10
|
class Card < Plutonium::UI::Component::Base
|
|
11
11
|
attr_reader :record, :resource_definition, :resource_fields, :card_fields
|
|
12
12
|
|
|
13
|
-
def initialize(record, resource_definition:, resource_fields: nil, card_fields: nil, show_turbo_frame: nil)
|
|
13
|
+
def initialize(record, resource_definition:, resource_fields: nil, card_fields: nil, show_turbo_frame: nil, drag_handle: nil, position_group: nil)
|
|
14
14
|
@record = record
|
|
15
|
+
# Which positioning group this card belongs to, for a scoped resource;
|
|
16
|
+
# nil when unscoped. Same rationale as the table's row attribute — the
|
|
17
|
+
# drag refuses a drop across groups (see Positionable).
|
|
18
|
+
@position_group = position_group
|
|
15
19
|
@resource_definition = resource_definition
|
|
16
20
|
@resource_fields = resource_fields
|
|
17
21
|
@card_fields = card_fields
|
|
22
|
+
# A prebuilt Table::Components::DragHandle, or nil for a card that is
|
|
23
|
+
# not reorderable. The GRID owns the decision — whether the resource
|
|
24
|
+
# declares `position_on`, whether the collection is in position order,
|
|
25
|
+
# whether this viewer passes `reposition?` — because all three are
|
|
26
|
+
# properties of the collection and its policy, not of one card. The
|
|
27
|
+
# card only knows where to put what it was handed.
|
|
28
|
+
@drag_handle = drag_handle
|
|
18
29
|
# Overrides the show link's turbo-frame target. Defaults to the show
|
|
19
30
|
# action's own frame (nil → normal navigation). The kanban board sets
|
|
20
31
|
# "_top" so a card click escapes its column's lazy turbo-frame instead
|
|
@@ -23,11 +34,9 @@ module Plutonium
|
|
|
23
34
|
end
|
|
24
35
|
|
|
25
36
|
def view_template
|
|
26
|
-
article(
|
|
27
|
-
class: card_class,
|
|
28
|
-
data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"}
|
|
29
|
-
) do
|
|
37
|
+
article(class: card_class, data: card_data) do
|
|
30
38
|
render_show_link if can_show?
|
|
39
|
+
render_drag_handle if @drag_handle
|
|
31
40
|
render_actions_dropdown
|
|
32
41
|
case resource_definition.defined_grid_layout
|
|
33
42
|
when :media then render_media_layout
|
|
@@ -76,8 +85,16 @@ module Plutonium
|
|
|
76
85
|
# Footer falls back to `:created_at` when the slot is unset and
|
|
77
86
|
# the record has a created_at column. Gives cards a sensible
|
|
78
87
|
# second line without forcing every grid_fields call to repeat it.
|
|
88
|
+
#
|
|
89
|
+
# That fallback means omitting the slot does NOT remove the footer —
|
|
90
|
+
# unlike every other slot, whose `if slots[:x]` guard collapses it. Pass
|
|
91
|
+
# `footer: false` to opt out. nil still means "unset" so a slot hash built
|
|
92
|
+
# from a nil variable keeps falling back rather than silently losing it.
|
|
79
93
|
def footer_field
|
|
80
|
-
slots[:footer]
|
|
94
|
+
footer = slots[:footer]
|
|
95
|
+
return nil if footer == false
|
|
96
|
+
|
|
97
|
+
footer || (record.respond_to?(:created_at) ? :created_at : nil)
|
|
81
98
|
end
|
|
82
99
|
|
|
83
100
|
# ---------------------------------------------------------------
|
|
@@ -219,6 +236,16 @@ module Plutonium
|
|
|
219
236
|
# Card chrome — selection, actions, show
|
|
220
237
|
# ---------------------------------------------------------------
|
|
221
238
|
|
|
239
|
+
# The grip positions itself (Table::Theme's :drag_handle_card floats it
|
|
240
|
+
# over the card's top-left corner), so there is no wrapper here — unlike
|
|
241
|
+
# the table, where the grip has to ride inside a specific cell.
|
|
242
|
+
#
|
|
243
|
+
# It is a <button> — or an <a> when disabled — and row_click_controller
|
|
244
|
+
# ignores both, so grabbing the grip never navigates to the show page.
|
|
245
|
+
def render_drag_handle
|
|
246
|
+
render @drag_handle
|
|
247
|
+
end
|
|
248
|
+
|
|
222
249
|
def render_actions_dropdown
|
|
223
250
|
# Cards have limited surface area, so all collection-record
|
|
224
251
|
# actions (including primary ones like Edit) live in the
|
|
@@ -236,20 +263,42 @@ module Plutonium
|
|
|
236
263
|
# action button works in the Table view.
|
|
237
264
|
def render_show_link
|
|
238
265
|
show = resource_definition.defined_actions[:show]
|
|
239
|
-
url = route_options_to_url(show.route_options, record)
|
|
240
|
-
a(
|
|
266
|
+
url = merge_query_params(route_options_to_url(show.route_options, record), show_link_url_params)
|
|
267
|
+
a(**show.link_attributes({
|
|
241
268
|
href: url,
|
|
242
269
|
data: {row_click_target: "show", turbo_frame: @show_turbo_frame || show.turbo_frame(resource_definition)},
|
|
243
270
|
class: "sr-only",
|
|
244
271
|
tabindex: "-1",
|
|
245
272
|
"aria-label": "Open #{header_text}"
|
|
246
|
-
) { plain "Open" }
|
|
273
|
+
})) { plain "Open" }
|
|
247
274
|
end
|
|
248
275
|
|
|
249
276
|
# ---------------------------------------------------------------
|
|
250
277
|
# Helpers
|
|
251
278
|
# ---------------------------------------------------------------
|
|
252
279
|
|
|
280
|
+
# Extra query params for the show link. A kanban card on a show_in :modal
|
|
281
|
+
# board is the only case a Grid::Card is handed the remote-modal frame
|
|
282
|
+
# explicitly (the grid/table reach that frame via the show action's own
|
|
283
|
+
# default, leaving @show_turbo_frame nil). Since a regular modal show and
|
|
284
|
+
# a kanban card's modal share the frame, tag the kanban one so the show
|
|
285
|
+
# page's in_kanban_modal? can tell them apart and drop the metadata rail.
|
|
286
|
+
def show_link_url_params
|
|
287
|
+
{Plutonium::KANBAN_MODAL_PARAM => "1"} if @show_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Merges extra query params into a URL, preserving any the base URL
|
|
291
|
+
# already carries (e.g. a nested resource's parent id). Returns the URL
|
|
292
|
+
# unchanged when there's nothing to add.
|
|
293
|
+
def merge_query_params(url, extra)
|
|
294
|
+
return url if extra.blank?
|
|
295
|
+
|
|
296
|
+
uri = URI.parse(url)
|
|
297
|
+
merged = Rack::Utils.parse_nested_query(uri.query).merge(extra.stringify_keys)
|
|
298
|
+
uri.query = merged.to_query.presence
|
|
299
|
+
uri.to_s
|
|
300
|
+
end
|
|
301
|
+
|
|
253
302
|
def header_text
|
|
254
303
|
@header_text ||= helpers.display_name_of(field_value(slots[:header]) || record)
|
|
255
304
|
end
|
|
@@ -270,7 +319,7 @@ module Plutonium
|
|
|
270
319
|
|
|
271
320
|
def row_actions
|
|
272
321
|
@row_actions ||= resource_definition.defined_actions.values.select { |a|
|
|
273
|
-
a.collection_record_action? && !a.
|
|
322
|
+
a.collection_record_action? && !a.hidden? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
|
|
274
323
|
}
|
|
275
324
|
end
|
|
276
325
|
|
|
@@ -286,9 +335,28 @@ module Plutonium
|
|
|
286
335
|
def card_class
|
|
287
336
|
tokens(
|
|
288
337
|
"pu-card relative overflow-hidden transition-shadow",
|
|
289
|
-
-> { can_show? } => "cursor-pointer hover:shadow-md focus-within:ring-2 focus-within:ring-primary-500"
|
|
338
|
+
-> { can_show? } => "cursor-pointer hover:shadow-md focus-within:ring-2 focus-within:ring-primary-500",
|
|
339
|
+
# `group/card` is what lets the grip reveal itself on card hover.
|
|
340
|
+
# NAMED, so a card rendered inside some other hover group (or a
|
|
341
|
+
# future group inside the card) can never reveal it by accident.
|
|
342
|
+
-> { !@drag_handle.nil? } => "group/card"
|
|
290
343
|
)
|
|
291
344
|
end
|
|
345
|
+
|
|
346
|
+
# The card — never the grip — is the single source of truth for which
|
|
347
|
+
# record a drag is about. See positioned_controller.js.
|
|
348
|
+
#
|
|
349
|
+
# Carried whenever a grip is rendered, including the disabled one: with
|
|
350
|
+
# no controller wired to the grid the attribute is inert, and making it
|
|
351
|
+
# conditional on the sort would mean the card had to know about the
|
|
352
|
+
# collection's ordering for no gain.
|
|
353
|
+
def card_data
|
|
354
|
+
data = {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"}
|
|
355
|
+
return data unless @drag_handle
|
|
356
|
+
|
|
357
|
+
data = data.merge(positioned_row_id: record.id)
|
|
358
|
+
@position_group.nil? ? data : data.merge(positioned_group: @position_group)
|
|
359
|
+
end
|
|
292
360
|
end
|
|
293
361
|
end
|
|
294
362
|
end
|