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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
# Staging and revival for attachments that travel as PLAIN STRINGS —
|
|
5
|
+
# backend-agnostic and model-free.
|
|
6
|
+
#
|
|
7
|
+
# Two subsystems need this and neither has a model to hang an attachment off
|
|
8
|
+
# while the work is in flight: a wizard stages a file across steps, and an
|
|
9
|
+
# async interaction has to carry one from the request into a job. Both hold a
|
|
10
|
+
# bare `attribute :photo, :string`, so the value has to BE a string.
|
|
11
|
+
#
|
|
12
|
+
# Two shapes exist, and they are distinguishable — ActiveStorage's **signed_id**
|
|
13
|
+
# is opaque, Shrine's **cached-file data** parses as JSON — so each revives
|
|
14
|
+
# through its own backend with no model and no per-field configuration.
|
|
15
|
+
#
|
|
16
|
+
# Revival is deliberately separate from staging. {stage_upload} runs in the
|
|
17
|
+
# request, where the file is; {resolve} runs wherever the value is read, which
|
|
18
|
+
# may be a render or a job. Assigning a token straight to a model attachment
|
|
19
|
+
# (both backends accept one) needs neither.
|
|
20
|
+
#
|
|
21
|
+
# Backend objects answer DIFFERENT method names (`filename`/`content_type` vs
|
|
22
|
+
# `original_filename`/`mime_type`), so a resolved token is wrapped in
|
|
23
|
+
# {Resolved}, a uniform view over both.
|
|
24
|
+
module Attachments
|
|
25
|
+
# Options on a file input that are consumed SERVER-SIDE to stage the upload,
|
|
26
|
+
# and are never form or HTML concerns.
|
|
27
|
+
#
|
|
28
|
+
# They have to be kept out of what the form renders: Phlex rejects a
|
|
29
|
+
# Class-valued +uploader:+ as an attribute outright, and +backend:+ would
|
|
30
|
+
# leak as a stray one. Both the wizard (Wizard::StepAdapter) and an
|
|
31
|
+
# interaction's form read this list, so the two cannot drift.
|
|
32
|
+
STAGING_ONLY_INPUT_OPTIONS = %i[backend uploader].freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Resolve a staged attachment token (or array of them) into uniform
|
|
37
|
+
# {Resolved} view(s).
|
|
38
|
+
#
|
|
39
|
+
# @param value [String, Array, nil] the staged token(s).
|
|
40
|
+
# @return [Array<Resolved>] resolved attachments; blank, tampered, or
|
|
41
|
+
# unrecognized tokens are dropped (never raised), so a bad token can't 500
|
|
42
|
+
# the form or the review.
|
|
43
|
+
def resolve(value)
|
|
44
|
+
Array(value).filter_map { |token| resolve_token(token) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# SERVER-SIDE staging: turn a submitted attachment value into a token string
|
|
48
|
+
# to stage in `data`, minting one from an uploaded file when needed.
|
|
49
|
+
#
|
|
50
|
+
# Handles every shape a step POST can carry for an attachment field:
|
|
51
|
+
# - an already-minted token String (direct upload, or the hidden preview field
|
|
52
|
+
# on re-submit) → kept verbatim;
|
|
53
|
+
# - an uploaded file (IO-like) → uploaded to the backend's cache, returning its
|
|
54
|
+
# token (an AS signed_id, or Shrine cached-file JSON);
|
|
55
|
+
# - blank / no selection → nil (the caller drops the key so the previously
|
|
56
|
+
# staged token survives a Back/re-submit);
|
|
57
|
+
# - an Array (multiple) → each element mapped, blanks dropped.
|
|
58
|
+
#
|
|
59
|
+
# @param backend [Symbol, nil] per-field override; nil → the configured default.
|
|
60
|
+
# @param uploader [Class, String, nil] a Shrine uploader to cache through
|
|
61
|
+
# (`:shrine` backend only) — its cache-stage plugins (mime/dimension
|
|
62
|
+
# extraction, `generate_location`, validations) run instead of base Shrine's.
|
|
63
|
+
# The minted token stays uploader-agnostic, so display + `execute` promotion
|
|
64
|
+
# are unaffected. Ignored shape for ActiveStorage (raises if given).
|
|
65
|
+
def stage_upload(value, backend: nil, uploader: nil)
|
|
66
|
+
if value.is_a?(Array)
|
|
67
|
+
value.filter_map { |v| stage_upload(v, backend:, uploader:) }.presence
|
|
68
|
+
elsif value.is_a?(String)
|
|
69
|
+
value.presence
|
|
70
|
+
elsif value.respond_to?(:read)
|
|
71
|
+
upload_to_cache(value, backend || default_backend, uploader:)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The default staging backend when a caller names none: the globally
|
|
76
|
+
# configured one, else auto-detected (active_shrine loaded → Shrine, else
|
|
77
|
+
# ActiveStorage).
|
|
78
|
+
#
|
|
79
|
+
# Subsystems layer their own override on top of this rather than replacing
|
|
80
|
+
# it — see Wizard::Attachments.attachment_backend.
|
|
81
|
+
def default_backend
|
|
82
|
+
Plutonium.configuration.attachment_backend ||
|
|
83
|
+
(defined?(ActiveShrine) ? :shrine : :active_storage)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Run the EFFECTIVE Shrine uploader's attacher validations against a staged
|
|
87
|
+
# token (or array of them), returning the validation messages — so a file that
|
|
88
|
+
# violates the uploader's `validate_*` rules is rejected at the STEP (stage
|
|
89
|
+
# phase), not deferred to `execute`'s model assignment.
|
|
90
|
+
#
|
|
91
|
+
# The effective uploader is the field's `uploader:` if given, else base
|
|
92
|
+
# `Shrine` — both of which may carry `Attacher.validate` rules. Returns `[]`
|
|
93
|
+
# when the field isn't Shrine-backed (ActiveStorage has no attacher here), when
|
|
94
|
+
# nothing is staged, or when the effective uploader declares no validations.
|
|
95
|
+
#
|
|
96
|
+
# @param value [String, Array, nil] the staged token(s).
|
|
97
|
+
# @param backend [Symbol, nil] per-field override; nil → the configured default.
|
|
98
|
+
# @param uploader [Class, String, nil] the field's `uploader:` option.
|
|
99
|
+
# @return [Array<String>] validation messages (empty ⇒ valid).
|
|
100
|
+
def validation_errors(value, backend: nil, uploader: nil)
|
|
101
|
+
return [] unless (backend || default_backend).to_sym == :shrine
|
|
102
|
+
|
|
103
|
+
klass = shrine_uploader(uploader)
|
|
104
|
+
# Shrine's `validation` plugin is OPTIONAL — without it (or `validation_helpers`)
|
|
105
|
+
# the Attacher has no `#errors` and nothing to enforce. Detect it up front so a
|
|
106
|
+
# plugin-less app is a clean no-op, not a per-step rescued NoMethodError.
|
|
107
|
+
return [] unless klass::Attacher.method_defined?(:errors)
|
|
108
|
+
|
|
109
|
+
Array(value).flat_map { |token| token_validation_errors(klass, token) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Validate one cached token through an uploader's attacher. A broad rescue
|
|
113
|
+
# (like {resolve_token}) — a tampered/expired token shouldn't 500 the step; it
|
|
114
|
+
# surfaces at `execute` instead, where it's caught as a RecordInvalid.
|
|
115
|
+
def token_validation_errors(uploader_class, token)
|
|
116
|
+
return [] if token.blank?
|
|
117
|
+
|
|
118
|
+
attacher = uploader_class::Attacher.new
|
|
119
|
+
attacher.assign(token)
|
|
120
|
+
Array(attacher.errors)
|
|
121
|
+
rescue => e
|
|
122
|
+
Rails.logger.warn { "[Plutonium] attachment validation skipped: #{e.class}: #{e.message}" }
|
|
123
|
+
[]
|
|
124
|
+
end
|
|
125
|
+
private_class_method :token_validation_errors
|
|
126
|
+
|
|
127
|
+
# Upload a file to the backend's CACHE and return its re-postable token. The
|
|
128
|
+
# file lives in cache until `execute` assigns the token to a real attachment
|
|
129
|
+
# (which promotes it); an abandoned upload is reaped by the backend's own
|
|
130
|
+
# unattached-cache cleanup.
|
|
131
|
+
def upload_to_cache(file, backend, uploader: nil)
|
|
132
|
+
case backend.to_sym
|
|
133
|
+
when :shrine
|
|
134
|
+
shrine_uploader(uploader).upload(file, :cache).to_json
|
|
135
|
+
when :active_storage
|
|
136
|
+
raise ArgumentError, "`uploader:` is only supported for the :shrine backend" if uploader
|
|
137
|
+
ActiveStorage::Blob.create_and_upload!(
|
|
138
|
+
io: file, filename: file.original_filename, content_type: file.content_type
|
|
139
|
+
).signed_id
|
|
140
|
+
else
|
|
141
|
+
raise ArgumentError, "unknown attachment backend: #{backend.inspect}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
private_class_method :upload_to_cache
|
|
145
|
+
|
|
146
|
+
# Resolve an `uploader:` option to the Shrine uploader class to cache through.
|
|
147
|
+
# nil → base `Shrine`; a class is used as-is; a String/Symbol is constantized.
|
|
148
|
+
# Anything that isn't a Shrine subclass is a configuration error (fail loud).
|
|
149
|
+
def shrine_uploader(uploader)
|
|
150
|
+
return Shrine if uploader.nil?
|
|
151
|
+
|
|
152
|
+
klass = uploader.is_a?(Class) ? uploader : uploader.to_s.safe_constantize
|
|
153
|
+
unless klass.is_a?(Class) && klass <= Shrine
|
|
154
|
+
raise ArgumentError, "`uploader:` must be a Shrine uploader class, got #{uploader.inspect}"
|
|
155
|
+
end
|
|
156
|
+
klass
|
|
157
|
+
end
|
|
158
|
+
private_class_method :shrine_uploader
|
|
159
|
+
|
|
160
|
+
# Revive one token through whichever backend owns it, wrapped in {Resolved}. A
|
|
161
|
+
# broad rescue is warranted here (unlike elsewhere): the token is arbitrary,
|
|
162
|
+
# user-supplied input reconstituted at a render boundary, and the two backends
|
|
163
|
+
# raise different error classes for a tampered/expired token — none of which
|
|
164
|
+
# should take down the page.
|
|
165
|
+
def resolve_token(token)
|
|
166
|
+
return if token.blank?
|
|
167
|
+
|
|
168
|
+
source = shrine_uploaded_file(token) || active_storage_blob(token)
|
|
169
|
+
source && Resolved.new(source, token)
|
|
170
|
+
rescue => e
|
|
171
|
+
Rails.logger.warn { "[Plutonium] could not resolve attachment token: #{e.class}: #{e.message}" }
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
private_class_method :resolve_token
|
|
175
|
+
|
|
176
|
+
# A Shrine cached-file token is JSON (`{"id":…,"storage":"cache",…}`); an AS
|
|
177
|
+
# signed_id isn't. Parse-success → Shrine materializes it from the globally
|
|
178
|
+
# registered storages (no model, no per-field uploader needed for a `.url`).
|
|
179
|
+
def shrine_uploaded_file(token)
|
|
180
|
+
return unless defined?(Shrine)
|
|
181
|
+
|
|
182
|
+
data = begin
|
|
183
|
+
JSON.parse(token)
|
|
184
|
+
rescue JSON::ParserError, TypeError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
return unless data.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
Shrine.uploaded_file(data)
|
|
190
|
+
end
|
|
191
|
+
private_class_method :shrine_uploaded_file
|
|
192
|
+
|
|
193
|
+
def active_storage_blob(token)
|
|
194
|
+
ActiveStorage::Blob.find_signed(token) if defined?(ActiveStorage::Blob)
|
|
195
|
+
end
|
|
196
|
+
private_class_method :active_storage_blob
|
|
197
|
+
|
|
198
|
+
# A uniform view over a resolved attachment so the review display + the uppy
|
|
199
|
+
# preview don't care whether the source is an ActiveStorage `Blob`
|
|
200
|
+
# (`filename`/`content_type`/`representable?`) or a Shrine `UploadedFile`
|
|
201
|
+
# (`original_filename`/`mime_type`, none of the AS-only methods). Exposes
|
|
202
|
+
# exactly what those components call.
|
|
203
|
+
class Resolved
|
|
204
|
+
# @param source the backend object (AS Blob or Shrine UploadedFile).
|
|
205
|
+
# @param token [String] the ORIGINAL staged token — what the hidden preview
|
|
206
|
+
# field re-posts to preserve the upload across a Back/re-submit, and what
|
|
207
|
+
# `execute` assigns to the model attachment.
|
|
208
|
+
def initialize(source, token)
|
|
209
|
+
@source = source
|
|
210
|
+
@token = token
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# `url` is lazy (called at render, inside a request, where AS url options
|
|
214
|
+
# exist) — never eager at resolve time.
|
|
215
|
+
def url(*args) = @source.url(*args)
|
|
216
|
+
|
|
217
|
+
# The re-postable token, surfaced under the name the uppy input reads.
|
|
218
|
+
def signed_id = @token
|
|
219
|
+
|
|
220
|
+
def filename = (@source.try(:filename) || @source.try(:original_filename)).to_s
|
|
221
|
+
|
|
222
|
+
def content_type = @source.try(:content_type) || @source.try(:mime_type)
|
|
223
|
+
|
|
224
|
+
def representable? = @source.try(:representable?) || content_type.to_s.start_with?("image/")
|
|
225
|
+
|
|
226
|
+
def extension = @source.try(:extension).presence || File.extname(filename).delete(".").presence
|
|
227
|
+
|
|
228
|
+
# Yields the attachment as an open file, cleaned up afterwards.
|
|
229
|
+
#
|
|
230
|
+
# {url} is enough for a wizard, which only ever renders a staged
|
|
231
|
+
# attachment. Work that RUNS on one — an import parsing the rows, a job
|
|
232
|
+
# promoting the file onto a model — has to read it, and in a job there is
|
|
233
|
+
# no request to build a URL against anyway.
|
|
234
|
+
#
|
|
235
|
+
# @yieldparam [File, Tempfile] an IO positioned at the start
|
|
236
|
+
# @return the block's value
|
|
237
|
+
def open(&) = @source.open(&)
|
|
238
|
+
|
|
239
|
+
# The bytes, as a String.
|
|
240
|
+
#
|
|
241
|
+
# Routed through {open} rather than the backends' own +download+, because
|
|
242
|
+
# that is one of the names they disagree on: ActiveStorage's returns the
|
|
243
|
+
# bytes, Shrine's returns a Tempfile. Papering over exactly that kind of
|
|
244
|
+
# divergence is what this class is for, so a caller can read a staged file
|
|
245
|
+
# without knowing which backend minted its token.
|
|
246
|
+
# Explicit receiver: a bare `open` here is indistinguishable from
|
|
247
|
+
# `Kernel#open` to a reader (and to the linter), which is a very different
|
|
248
|
+
# and much more dangerous method.
|
|
249
|
+
def download = self.open(&:read)
|
|
250
|
+
|
|
251
|
+
def present? = true
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -25,8 +25,21 @@ module Plutonium
|
|
|
25
25
|
attr_reader :assets
|
|
26
26
|
|
|
27
27
|
# @return [Plutonium::Wizard::Configuration] wizard subsystem configuration
|
|
28
|
+
# @return [Symbol, nil] the storage backend used to stage an attachment that
|
|
29
|
+
# travels as a plain string — `:active_storage` or `:shrine`. `nil`
|
|
30
|
+
# auto-detects (active_shrine loaded → `:shrine`, else `:active_storage`).
|
|
31
|
+
#
|
|
32
|
+
# The shared default. Wizards and async interactions each layer their own
|
|
33
|
+
# override on top, so setting this once covers both, and setting one of
|
|
34
|
+
# theirs narrows it to that subsystem.
|
|
35
|
+
attr_accessor :attachment_backend
|
|
36
|
+
|
|
28
37
|
attr_reader :wizards
|
|
29
38
|
|
|
39
|
+
# @return [Plutonium::Interaction::Async::Configuration] persisted interaction
|
|
40
|
+
# runs configuration — gates the runs subsystem and its migrations
|
|
41
|
+
attr_reader :async_interactions
|
|
42
|
+
|
|
30
43
|
# @return [Float] the current defaults version
|
|
31
44
|
attr_reader :defaults_version
|
|
32
45
|
|
|
@@ -47,10 +60,74 @@ module Plutonium
|
|
|
47
60
|
# change the default, or `false` (or `""`) for no symbol application-wide.
|
|
48
61
|
attr_accessor :default_currency_unit
|
|
49
62
|
|
|
63
|
+
# @return [Symbol] the default width for detail-style pages — the show
|
|
64
|
+
# page, resource forms and wizard steps. One of
|
|
65
|
+
# {Plutonium::UI::PageWidth::VALID_SIZES}; `:full` opts out of any
|
|
66
|
+
# constraint. Definitions override per-resource via `page_width`
|
|
67
|
+
# (and `form_width` / `display_width` for one surface only).
|
|
68
|
+
#
|
|
69
|
+
# Index and table pages are NOT affected — they want every pixel.
|
|
70
|
+
attr_accessor :default_page_width
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] whether a rendered collection — an index page, a kanban
|
|
73
|
+
# board, a CSV export — eager-loads the associations and attachments it is
|
|
74
|
+
# about to show. On by default.
|
|
75
|
+
#
|
|
76
|
+
# The rendered field set is declared, not discovered: it is resolved from
|
|
77
|
+
# the policy before the collection loads, so the framework already knows
|
|
78
|
+
# which associations will be touched and can preload exactly those. Turn it
|
|
79
|
+
# off globally here, or per controller via `auto_eager_load_collections?`.
|
|
80
|
+
attr_accessor :auto_eager_load_collections
|
|
81
|
+
|
|
50
82
|
# @return [String, nil] the default country (ISO2 code, e.g. "gh") for phone
|
|
51
83
|
# (`as: :phone`) inputs that don't set their own `initial_country:`. `nil`
|
|
52
84
|
# (default) leaves it to the intl-tel-input library (no country preselected).
|
|
53
|
-
|
|
85
|
+
# Stored verbatim — read it back exactly as set. Consumers that feed
|
|
86
|
+
# intl-tel-input should use {#normalized_default_phone_country}.
|
|
87
|
+
attr_reader :default_phone_country
|
|
88
|
+
|
|
89
|
+
# @return [String, nil] {#default_phone_country} downcased to the lowercase
|
|
90
|
+
# ISO2 form intl-tel-input expects, so callers can set "GH" or "gh"
|
|
91
|
+
# interchangeably. `nil` stays `nil`. Computed once on assignment.
|
|
92
|
+
attr_reader :normalized_default_phone_country
|
|
93
|
+
|
|
94
|
+
def default_phone_country=(value)
|
|
95
|
+
@default_phone_country = value
|
|
96
|
+
@normalized_default_phone_country = value&.downcase
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Valid values for {#nested_association_routes}.
|
|
100
|
+
NESTED_ASSOCIATION_ROUTE_MODES = %i[detected declared].freeze
|
|
101
|
+
|
|
102
|
+
# @return [Symbol] where a resource's nested routes come from.
|
|
103
|
+
#
|
|
104
|
+
# `:detected` (default) draws one for every `has_many` and `has_one` whose
|
|
105
|
+
# child is a registered resource, which is how Plutonium has always behaved.
|
|
106
|
+
#
|
|
107
|
+
# `:declared` draws only what `register_resource` names:
|
|
108
|
+
#
|
|
109
|
+
# register_resource ::Post, associations: %i[comments post_detail]
|
|
110
|
+
#
|
|
111
|
+
# A resource that names none then gets no nested routes at all. Naming
|
|
112
|
+
# associations works in either mode; the mode only decides what silence means.
|
|
113
|
+
#
|
|
114
|
+
# Note that a policy's `permitted_associations` renders a panel on the show
|
|
115
|
+
# page that links to the nested route, so an association permitted there and
|
|
116
|
+
# omitted here has a panel with nowhere to point.
|
|
117
|
+
attr_reader :nested_association_routes
|
|
118
|
+
|
|
119
|
+
# @param value [Symbol] one of {NESTED_ASSOCIATION_ROUTE_MODES}
|
|
120
|
+
# @raise [ArgumentError] on any other value, rather than silently drawing
|
|
121
|
+
# the wrong route table for a typo.
|
|
122
|
+
def nested_association_routes=(value)
|
|
123
|
+
value = value.to_sym
|
|
124
|
+
unless NESTED_ASSOCIATION_ROUTE_MODES.include?(value)
|
|
125
|
+
raise ArgumentError, "unknown nested_association_routes #{value.inspect}. " \
|
|
126
|
+
"Valid modes: #{NESTED_ASSOCIATION_ROUTE_MODES.map(&:inspect).join(", ")}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@nested_association_routes = value
|
|
130
|
+
end
|
|
54
131
|
|
|
55
132
|
# Map of version numbers to their default configurations
|
|
56
133
|
VERSION_DEFAULTS = {
|
|
@@ -70,12 +147,16 @@ module Plutonium
|
|
|
70
147
|
@defaults_version = nil
|
|
71
148
|
@assets = AssetConfiguration.new
|
|
72
149
|
@wizards = Plutonium::Wizard::Configuration.new
|
|
150
|
+
@async_interactions = Plutonium::Interaction::Async::Configuration.new
|
|
73
151
|
|
|
74
152
|
@development = parse_boolean_env("PLUTONIUM_DEV")
|
|
75
153
|
@cache_discovery = !Rails.env.development?
|
|
76
154
|
@enable_hotreload = Rails.env.development?
|
|
77
155
|
@shell = :modern
|
|
78
156
|
@navii_host_url = "https://api.navii.dev"
|
|
157
|
+
@auto_eager_load_collections = true
|
|
158
|
+
@default_page_width = :md
|
|
159
|
+
@nested_association_routes = :detected
|
|
79
160
|
end
|
|
80
161
|
|
|
81
162
|
# Load default configuration for a specific version
|
|
@@ -40,7 +40,8 @@ module Plutonium
|
|
|
40
40
|
|
|
41
41
|
helper Plutonium::Helpers
|
|
42
42
|
helper_method :make_page_title, :resource_url_for,
|
|
43
|
-
:resource_url_args_for, :root_path, :app_name, :route_options_to_url
|
|
43
|
+
:resource_url_args_for, :root_path, :app_name, :route_options_to_url,
|
|
44
|
+
:current_page_path, :current_page_url
|
|
44
45
|
|
|
45
46
|
append_view_path File.expand_path("app/views", Plutonium.root)
|
|
46
47
|
layout -> { turbo_frame_request? ? false : "resource" }
|
|
@@ -140,7 +141,7 @@ module Plutonium
|
|
|
140
141
|
|
|
141
142
|
if wizard
|
|
142
143
|
raise ArgumentError, "cannot pass both `wizard:` and `action:`" if action
|
|
143
|
-
action = wizard_action_type_for(element, step: kwargs[:step])
|
|
144
|
+
action = wizard_action_type_for(element, step: kwargs[:step], wizard_action: kwargs.delete(:wizard_action))
|
|
144
145
|
kwargs[:wizard_name] = wizard
|
|
145
146
|
end
|
|
146
147
|
|
|
@@ -171,6 +172,24 @@ module Plutonium
|
|
|
171
172
|
build_top_level_resource_url_args(element, action: action, **kwargs)
|
|
172
173
|
end
|
|
173
174
|
|
|
175
|
+
# The path/URL of the PAGE this render belongs to.
|
|
176
|
+
#
|
|
177
|
+
# Almost always the current request — but not always. An action that
|
|
178
|
+
# answers a turbo_stream on behalf of a page the user is already looking
|
|
179
|
+
# at (the reposition drop POST, for one) renders that page's components
|
|
180
|
+
# from a DIFFERENT request path, and every URL those components build —
|
|
181
|
+
# sort links, the search form action, filter-pill removals, pagination,
|
|
182
|
+
# a row action's return_to — must point at the page, not at the POST
|
|
183
|
+
# endpoint. Reading them off `request` directly would bake the endpoint
|
|
184
|
+
# path into all of those links (a GET of the POST-only route → 404).
|
|
185
|
+
#
|
|
186
|
+
# Overriding these two is therefore the single seam for "render this
|
|
187
|
+
# collection as the index request would have rendered it"; see
|
|
188
|
+
# Plutonium::Resource::Controllers::PositionActions.
|
|
189
|
+
def current_page_path = request.path
|
|
190
|
+
|
|
191
|
+
def current_page_url = request.original_url
|
|
192
|
+
|
|
174
193
|
def resource_url_for(*args, package: nil, **kwargs)
|
|
175
194
|
url_args = resource_url_args_for(*args, package: package, **kwargs)
|
|
176
195
|
url_helpers = route_url_helpers_for(package)
|
|
@@ -204,12 +223,24 @@ module Plutonium
|
|
|
204
223
|
# classes/symbols/nil → collection (wizards have no bulk variant). With no
|
|
205
224
|
# `step:`, target the bare LAUNCH action (which resolves the run and redirects
|
|
206
225
|
# to its current step); with a `step:`, target the stepped show action.
|
|
207
|
-
|
|
226
|
+
#
|
|
227
|
+
# `wizard_action: :cancel` targets the DELETE action that abandons the run.
|
|
228
|
+
# A run is cancelled as a whole, so that route carries no `:step` segment and
|
|
229
|
+
# `step:` is ignored for it.
|
|
230
|
+
def wizard_action_type_for(element, step: nil, wizard_action: nil)
|
|
208
231
|
member = !(element.is_a?(Class) || element.is_a?(Symbol) || element.nil?)
|
|
209
|
-
|
|
210
|
-
|
|
232
|
+
|
|
233
|
+
case wizard_action
|
|
234
|
+
when nil
|
|
235
|
+
if step.nil?
|
|
236
|
+
member ? :launch_wizard_record_action : :launch_wizard_resource_action
|
|
237
|
+
else
|
|
238
|
+
member ? :wizard_record_action : :wizard_resource_action
|
|
239
|
+
end
|
|
240
|
+
when :cancel
|
|
241
|
+
member ? :cancel_wizard_record_action : :cancel_wizard_resource_action
|
|
211
242
|
else
|
|
212
|
-
|
|
243
|
+
raise ArgumentError, "unknown `wizard_action:` #{wizard_action.inspect} (expected :cancel or nil)"
|
|
213
244
|
end
|
|
214
245
|
end
|
|
215
246
|
|
|
@@ -257,7 +288,19 @@ module Plutonium
|
|
|
257
288
|
"#{scoped_entity_param_key}_"
|
|
258
289
|
end
|
|
259
290
|
|
|
260
|
-
|
|
291
|
+
# For association names that singularize to themselves ("comment_series",
|
|
292
|
+
# "news", anything ending in an uncountable word), the member and
|
|
293
|
+
# collection helpers above resolve to the same string. Rails breaks that
|
|
294
|
+
# tie by suffixing the collection route with _index, exactly as it does
|
|
295
|
+
# for top-level resources — without matching it here the helper named
|
|
296
|
+
# does not exist, url_for falls back to param recall, and a collection
|
|
297
|
+
# link comes back as a member action with no id.
|
|
298
|
+
uncountable_index_suffix = if is_collection_action && !is_singular &&
|
|
299
|
+
nested_resource_name.pluralize == nested_resource_name.singularize
|
|
300
|
+
"_index"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
helper_name = :"#{helper_suffix}#{entity_prefix}#{helper_base}#{uncountable_index_suffix}_path"
|
|
261
304
|
|
|
262
305
|
# Build the arguments for the helper
|
|
263
306
|
helper_args = []
|
|
@@ -48,6 +48,22 @@ module Plutonium
|
|
|
48
48
|
@current_scoped_entity if scoped_to_entity?
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Drops ActionPolicy's per-request authorization context memo.
|
|
52
|
+
#
|
|
53
|
+
# ActionPolicy builds the context once and memoizes it for the whole request.
|
|
54
|
+
# Our entity_scope resolves lazily, and the very first authorization of the
|
|
55
|
+
# request is the scoped entity's own read? check — which runs while
|
|
56
|
+
# @current_scoped_entity is still nil (see entity_scope_for_authorize above).
|
|
57
|
+
# That freezes `entity_scope: nil` into the memo, so every later
|
|
58
|
+
# `policy_for(record:)` / `allowed_to?` that does not pass an explicit
|
|
59
|
+
# `context:` builds its policy with no entity scope.
|
|
60
|
+
#
|
|
61
|
+
# Callers that resolve the scoped entity must invalidate the memo so the
|
|
62
|
+
# next policy build picks the entity up.
|
|
63
|
+
def reset_authorization_context!
|
|
64
|
+
@_authorization_context = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
51
67
|
def verify_authorized
|
|
52
68
|
# we don't use action policy's inbuilt checks, so ensure they are neutered,
|
|
53
69
|
# also ensures pundit checks are disabled.
|
|
@@ -77,8 +77,18 @@ module Plutonium
|
|
|
77
77
|
# this method might be invoked even when not authenticated.
|
|
78
78
|
# so let's guard against that.
|
|
79
79
|
return unless current_user.present?
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
return @current_scoped_entity if @current_scoped_entity
|
|
81
|
+
|
|
82
|
+
@current_scoped_entity = fetch_current_scoped_entity
|
|
83
|
+
# fetch_current_scoped_entity authorizes while @current_scoped_entity is
|
|
84
|
+
# still nil, which memoizes `entity_scope: nil` into ActionPolicy's
|
|
85
|
+
# per-request context. Drop it now that the entity is available.
|
|
86
|
+
#
|
|
87
|
+
# Only when we actually resolved one: a strategy that yields nil leaves
|
|
88
|
+
# the memo correct as-is, and since nil is not memoized above, resetting
|
|
89
|
+
# here would re-run the fetch and rebuild the context on every call.
|
|
90
|
+
reset_authorization_context! if @current_scoped_entity
|
|
91
|
+
@current_scoped_entity
|
|
82
92
|
end
|
|
83
93
|
|
|
84
94
|
# Fetches the current scoped entity based on the scoping strategy.
|
|
@@ -31,11 +31,16 @@ module Plutonium
|
|
|
31
31
|
include Actions
|
|
32
32
|
include Wizards
|
|
33
33
|
include Sorting
|
|
34
|
+
# After Actions and Sorting: `position_on` expands into `action`,
|
|
35
|
+
# `sort` and `default_sort`, so those must already be defined.
|
|
36
|
+
include Positioning
|
|
34
37
|
include Scoping
|
|
35
38
|
include Search
|
|
36
39
|
include NestedInputs
|
|
37
40
|
include StructuredInputs
|
|
38
41
|
include FormLayout
|
|
42
|
+
include DisplayLayout
|
|
43
|
+
include PageWidths
|
|
39
44
|
include IndexViews
|
|
40
45
|
include Metadata
|
|
41
46
|
|
|
@@ -145,6 +150,52 @@ module Plutonium
|
|
|
145
150
|
self.show_in_config = value
|
|
146
151
|
end
|
|
147
152
|
|
|
153
|
+
# The model this definition describes — the inverse of the
|
|
154
|
+
# `"#{resource_class}Definition"` lookup controllers do
|
|
155
|
+
# (Plutonium::Resource::Controller#resource_definition).
|
|
156
|
+
#
|
|
157
|
+
# A definition is frequently namespaced under a package or portal that
|
|
158
|
+
# the model is NOT (`AdminPortal::Blogging::TutorialDefinition` describes
|
|
159
|
+
# `Blogging::Tutorial`), so leading namespace segments are dropped one at
|
|
160
|
+
# a time until an ActiveRecord model resolves.
|
|
161
|
+
#
|
|
162
|
+
# One case is genuinely unresolvable by name alone: `Admin::UserDefinition`
|
|
163
|
+
# where BOTH `Admin::User` and `::User` are models. Innermost wins, since a
|
|
164
|
+
# namespaced definition most often describes the namespaced model — if that
|
|
165
|
+
# is the wrong guess, override `model_class` on the definition.
|
|
166
|
+
#
|
|
167
|
+
# Resolved lazily and memoized: it constantizes the model, and a
|
|
168
|
+
# definition class body must stay loadable without forcing that.
|
|
169
|
+
def self.model_class
|
|
170
|
+
@model_class ||= infer_model_class
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.infer_model_class
|
|
174
|
+
raise NameError, "cannot infer a model class for an anonymous definition; define `model_class` on it" if name.nil?
|
|
175
|
+
|
|
176
|
+
segments = name.split("::")
|
|
177
|
+
base = segments.pop.delete_suffix("Definition")
|
|
178
|
+
if base.empty?
|
|
179
|
+
raise NameError, "#{name} does not follow the `<Model>Definition` naming convention; define `model_class` on it"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Only an ActiveRecord model can be the answer, so a namespace module, a
|
|
183
|
+
# stdlib constant (Set, Process) or a same-named PORO is skipped rather
|
|
184
|
+
# than accepted — the search continues outward to the model that
|
|
185
|
+
# actually exists. Without this the first constant that merely *resolves*
|
|
186
|
+
# wins, and `Billing::OrderDefinition` in an app with no `Billing::Order`
|
|
187
|
+
# would silently memoize something that is not a model at all.
|
|
188
|
+
segments.size.downto(0) do |i|
|
|
189
|
+
klass = (segments.last(i) + [base]).join("::").safe_constantize
|
|
190
|
+
return klass if klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
raise NameError, "could not infer a model class from #{name}; define `model_class` on it"
|
|
194
|
+
end
|
|
195
|
+
private_class_method :infer_model_class
|
|
196
|
+
|
|
197
|
+
def model_class = self.class.model_class
|
|
198
|
+
|
|
148
199
|
def initialize
|
|
149
200
|
super
|
|
150
201
|
end
|