plutonium 0.60.5 → 0.61.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 +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- 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/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Wizard
|
|
6
|
+
# The terminal review step's body (§2.5), a small state machine (see
|
|
7
|
+
# {#render_mode}):
|
|
8
|
+
#
|
|
9
|
+
# - INCOMPLETE (a visible step is unvisited/invalid) → a "fix this" jump
|
|
10
|
+
# link per outstanding step + an auto-summary of what's entered so far.
|
|
11
|
+
# - COMPLETE + custom block → the author's block, rendered bare.
|
|
12
|
+
# - COMPLETE + no block, `summary: true` (default) → the auto-summary of
|
|
13
|
+
# every visible step's collected `data` (via SummaryDisplay).
|
|
14
|
+
# - COMPLETE + no block, `summary: false` → the built-in "ready to
|
|
15
|
+
# complete" panel (for a fully author-owned / chromeless review).
|
|
16
|
+
#
|
|
17
|
+
# The Finish button is rendered by the page (gated: disabled while any
|
|
18
|
+
# visible step is incomplete); this component only renders the body.
|
|
19
|
+
class Review < Plutonium::UI::Component::Base
|
|
20
|
+
# @param runner [Plutonium::Wizard::Runner]
|
|
21
|
+
# @param step_url [Proc] step_key → GET url, for the fix-this links.
|
|
22
|
+
def initialize(runner:, step_url:)
|
|
23
|
+
@runner = runner
|
|
24
|
+
@step_url = step_url
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def view_template
|
|
28
|
+
render_outstanding if show_outstanding?
|
|
29
|
+
render_summary if show_summary?
|
|
30
|
+
render_custom_block if show_custom?
|
|
31
|
+
render_ready if show_ready?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# The review body composes from these pieces (see the `review summary:`
|
|
37
|
+
# macro). Order of render: outstanding → summary → custom → ready.
|
|
38
|
+
#
|
|
39
|
+
# - outstanding banner — while any visible step is incomplete.
|
|
40
|
+
# - summary cards — whenever the auto-summary is on: the
|
|
41
|
+
# review-and-fix view when incomplete, the check-before-finish view
|
|
42
|
+
# when complete.
|
|
43
|
+
# - custom block — author content, ONLY once complete; sits BELOW
|
|
44
|
+
# the summary when summary is on, and REPLACES it when summary is off.
|
|
45
|
+
# - ready panel — complete + summary off + no custom block: the
|
|
46
|
+
# built-in "ready to complete" confirmation (chromeless finish).
|
|
47
|
+
def show_outstanding? = !complete?
|
|
48
|
+
|
|
49
|
+
def show_summary? = summary?
|
|
50
|
+
|
|
51
|
+
def show_custom? = complete? && !custom_block.nil?
|
|
52
|
+
|
|
53
|
+
def show_ready? = complete? && !summary? && custom_block.nil?
|
|
54
|
+
|
|
55
|
+
def complete? = @runner.incomplete_visible_steps.empty?
|
|
56
|
+
|
|
57
|
+
def summary? = @runner.current_step.summary?
|
|
58
|
+
|
|
59
|
+
def custom_block = @runner.current_step.block
|
|
60
|
+
|
|
61
|
+
def steps = @runner.visible_path.reject(&:review?)
|
|
62
|
+
|
|
63
|
+
# The step's typed `data` sub-object (`data.<step>`), the summary's source.
|
|
64
|
+
def step_data(step) = @runner.wizard.data[step.key]
|
|
65
|
+
|
|
66
|
+
def render_outstanding
|
|
67
|
+
incomplete = @runner.incomplete_visible_steps
|
|
68
|
+
return if incomplete.empty?
|
|
69
|
+
|
|
70
|
+
div(class: "pu-wizard-review-outstanding rounded-lg border border-warning-300 bg-warning-50 dark:border-warning-800 dark:bg-warning-950/30 p-4 mb-6", role: "alert") do
|
|
71
|
+
p(class: "text-sm font-medium text-[var(--pu-text)] mb-2") do
|
|
72
|
+
"Some steps still need attention before you can finish:"
|
|
73
|
+
end
|
|
74
|
+
ul(class: "space-y-1") do
|
|
75
|
+
incomplete.each do |step|
|
|
76
|
+
li do
|
|
77
|
+
a(
|
|
78
|
+
href: @step_url.call(step.key),
|
|
79
|
+
class: "text-sm font-medium text-primary-600 dark:text-primary-400 hover:underline",
|
|
80
|
+
data: {wizard_review_fix: step.key}
|
|
81
|
+
) { "Fix #{step.label}" }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_summary
|
|
89
|
+
div(class: "space-y-4") do
|
|
90
|
+
steps.each do |step|
|
|
91
|
+
fields = summary_fields(step)
|
|
92
|
+
structured = step.structured_inputs
|
|
93
|
+
next if fields.empty? && structured.empty?
|
|
94
|
+
|
|
95
|
+
render_step_card(step, fields, structured)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One step rendered as a card: a titled header strip (label + Edit) over a
|
|
101
|
+
# body that holds the scalar summary and any structured collections.
|
|
102
|
+
def render_step_card(step, fields, structured)
|
|
103
|
+
section(
|
|
104
|
+
class: "pu-wizard-review-step overflow-hidden rounded-[var(--pu-radius-lg)] border border-[var(--pu-border)] bg-[var(--pu-surface)] shadow-[var(--pu-shadow-sm)]",
|
|
105
|
+
data: {wizard_review_step: step.key}
|
|
106
|
+
) do
|
|
107
|
+
div(class: "flex items-center justify-between gap-3 border-b border-[var(--pu-border)] bg-[var(--pu-surface-alt)] px-5 py-3") do
|
|
108
|
+
h3(class: "text-sm font-semibold text-[var(--pu-text)]") { step.label.to_s }
|
|
109
|
+
a(
|
|
110
|
+
href: @step_url.call(step.key),
|
|
111
|
+
class: "shrink-0 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
|
112
|
+
) { "Edit" }
|
|
113
|
+
end
|
|
114
|
+
div(class: "px-5 py-4") do
|
|
115
|
+
# Decorate so attachment fields resolve to displayable attachments —
|
|
116
|
+
# the SummaryDisplay then renders them through the normal attachment
|
|
117
|
+
# display component, not the raw token string.
|
|
118
|
+
if fields.any?
|
|
119
|
+
render SummaryDisplay.new(
|
|
120
|
+
Plutonium::Wizard::AttachmentData.wrap(step_data(step), step),
|
|
121
|
+
fields:, inputs: step.inputs
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
render_structured(step) if structured.any?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def summary_fields(step)
|
|
130
|
+
step.attribute_schema.keys.map(&:to_sym)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Repeatable `structured_input` collections aren't scalar `data` attributes,
|
|
134
|
+
# so the SummaryDisplay can't render them — summarise each collection as a
|
|
135
|
+
# labelled list of its non-empty rows (blank trailing rows are dropped).
|
|
136
|
+
def render_structured(step)
|
|
137
|
+
data = step_data(step)
|
|
138
|
+
step.structured_inputs.each_key do |name|
|
|
139
|
+
rows = Array(data.public_send(name))
|
|
140
|
+
.map(&:to_h)
|
|
141
|
+
.reject { |row| row.values.all?(&:blank?) }
|
|
142
|
+
|
|
143
|
+
div(class: "pu-wizard-review-collection mt-4 first:mt-0", data: {wizard_review_collection: name}) do
|
|
144
|
+
h4(class: "mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--pu-text-muted)]") do
|
|
145
|
+
name.to_s.humanize
|
|
146
|
+
end
|
|
147
|
+
if rows.empty?
|
|
148
|
+
p(class: "text-sm text-[var(--pu-text-subtle)]") { "None" }
|
|
149
|
+
else
|
|
150
|
+
ul(class: "space-y-1.5") do
|
|
151
|
+
rows.each do |row|
|
|
152
|
+
li(class: "flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-[var(--pu-radius-md)] bg-[var(--pu-surface-alt)] px-3 py-2 text-sm text-[var(--pu-text)]") do
|
|
153
|
+
row.each do |key, value|
|
|
154
|
+
span do
|
|
155
|
+
span(class: "text-[var(--pu-text-muted)]") { "#{key.to_s.humanize}: " }
|
|
156
|
+
plain value.to_s
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The review step's custom block, rendered BARE — just a spacing + targeting
|
|
168
|
+
# wrapper, no surface/border/typography of its own — so the author has full
|
|
169
|
+
# control over the body's look.
|
|
170
|
+
def render_custom_block
|
|
171
|
+
div(class: "pu-wizard-review-custom", data: {wizard_review_custom: true}) do
|
|
172
|
+
instance_exec(@runner.wizard, &custom_block)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The built-in "ready to complete" panel: shown when everything is valid,
|
|
177
|
+
# there's no custom block, and the auto-summary is turned off (`summary:
|
|
178
|
+
# false`). A clean confirmation so a chromeless review still reads as done.
|
|
179
|
+
def render_ready
|
|
180
|
+
div(
|
|
181
|
+
class: "pu-wizard-review-ready flex flex-col items-center text-center py-6",
|
|
182
|
+
data: {wizard_review_ready: true}
|
|
183
|
+
) do
|
|
184
|
+
div(class: "mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-success-100 text-success-600 dark:bg-success-900/30 dark:text-success-400") do
|
|
185
|
+
render Phlex::TablerIcons::Check.new(class: "h-7 w-7")
|
|
186
|
+
end
|
|
187
|
+
h3(class: "text-lg font-semibold tracking-tight text-[var(--pu-text)]") { "You're all set" }
|
|
188
|
+
p(class: "mt-1.5 max-w-prose text-sm text-[var(--pu-text-muted)]") do
|
|
189
|
+
"Everything looks good. Click Finish to complete."
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Wizard
|
|
6
|
+
# The horizontal step indicator (§7). Renders the visible step path with a
|
|
7
|
+
# completed / current / upcoming state per step. Navigation mode gates which
|
|
8
|
+
# steps are clickable:
|
|
9
|
+
#
|
|
10
|
+
# - :linear → only already-visited steps link back (no forward jumps).
|
|
11
|
+
# - :free → any visited step links (still no forward jumps to unvisited).
|
|
12
|
+
#
|
|
13
|
+
# Branch-hidden steps are simply absent from `visible_path`, so they never
|
|
14
|
+
# appear here.
|
|
15
|
+
class Stepper < Plutonium::UI::Component::Base
|
|
16
|
+
# @param steps [Array<Step>] the visible path.
|
|
17
|
+
# @param current [Step] the current step.
|
|
18
|
+
# @param visited [Array<String>] visited (reached) step keys.
|
|
19
|
+
# @param complete [Array<String>] keys of steps that are actually complete
|
|
20
|
+
# (submitted AND valid) — distinct from `visited`. A step can be reached
|
|
21
|
+
# (e.g. the cursor landed on a branch step) without being completed.
|
|
22
|
+
# @param navigation [Symbol] :linear or :free.
|
|
23
|
+
# @param step_url [Proc] step_key → GET url.
|
|
24
|
+
def initialize(steps:, current:, visited:, navigation:, step_url:, complete: [])
|
|
25
|
+
@steps = steps
|
|
26
|
+
@current = current
|
|
27
|
+
@visited = visited.map(&:to_s)
|
|
28
|
+
@complete = complete.map(&:to_s).to_set
|
|
29
|
+
@navigation = navigation
|
|
30
|
+
@step_url = step_url
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def view_template
|
|
34
|
+
nav(class: "pu-wizard-stepper mb-7", aria_label: "Progress") do
|
|
35
|
+
ol(class: "pu-wizard-steps") do
|
|
36
|
+
@steps.each_with_index do |step, index|
|
|
37
|
+
render_step(step, index)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# One step: a numbered node on the connector track + a label beneath. Both
|
|
46
|
+
# are wrapped in a link when the step is reachable (CSS draws the track,
|
|
47
|
+
# node states, and the done check via [data-state]).
|
|
48
|
+
def render_step(step, index)
|
|
49
|
+
state = step_state(step)
|
|
50
|
+
# `data-terminal` marks the review node so the CSS suppresses the
|
|
51
|
+
# "completed" checkmark for it — it's the finish flag, never a done-check.
|
|
52
|
+
li(data: {state:, wizard_stepper_state: state, terminal: step.review? ? "true" : nil}) do
|
|
53
|
+
if clickable?(step, state)
|
|
54
|
+
a(href: @step_url.call(step.key), class: "pu-step-link",
|
|
55
|
+
aria_current: (state == :current) ? "step" : nil) do
|
|
56
|
+
render_node(step, index)
|
|
57
|
+
span(class: "pu-step-label") { step.label.to_s }
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
span(class: "pu-step-link") do
|
|
61
|
+
render_node(step, index)
|
|
62
|
+
span(class: "pu-step-label",
|
|
63
|
+
aria_current: (state == :current) ? "step" : nil) { step.label.to_s }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The node: a numbered badge for a real step. The terminal review step isn't
|
|
70
|
+
# a numbered step (it's the "finish line"), so it shows a flag icon instead —
|
|
71
|
+
# no step number, here or in the card header (see Page::Wizard).
|
|
72
|
+
def render_node(step, index)
|
|
73
|
+
span(class: "pu-step-node") do
|
|
74
|
+
if step.review?
|
|
75
|
+
render_review_icon
|
|
76
|
+
else
|
|
77
|
+
# Review is always last, so a real step's index in the full path is
|
|
78
|
+
# also its position among real steps → a clean 1-based badge.
|
|
79
|
+
span(class: "pu-step-number") { (index + 1).to_s }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_review_icon
|
|
85
|
+
render Phlex::TablerIcons::Flag.new(class: "pu-step-flag w-4 h-4")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A step's visual state. `:completed` (the done-check) means actually
|
|
89
|
+
# complete — submitted AND valid — NOT merely reached: a branch step the
|
|
90
|
+
# cursor landed on but that was never submitted (or is now invalid) is
|
|
91
|
+
# `:incomplete`, so the rail never claims a step is done when the review
|
|
92
|
+
# still lists it under "needs attention". The terminal review node is the
|
|
93
|
+
# finish flag, so it reads `:completed` once reached (its check is suppressed
|
|
94
|
+
# by `data-terminal`).
|
|
95
|
+
def step_state(step)
|
|
96
|
+
return :current if step.key.to_s == @current&.key.to_s
|
|
97
|
+
if @visited.include?(step.key.to_s)
|
|
98
|
+
return :completed if step.review? || @complete.include?(step.key.to_s)
|
|
99
|
+
return :incomplete
|
|
100
|
+
end
|
|
101
|
+
:upcoming
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Which step headers link. Mirrors the engine's `Runner#go_to` reachability
|
|
105
|
+
# so the stepper never offers (or withholds) a jump the engine would reject:
|
|
106
|
+
#
|
|
107
|
+
# - the current step never links to itself;
|
|
108
|
+
# - the terminal review step links once the flow has started (any step
|
|
109
|
+
# visited) — it's never itself "visited" (you Finish from it, you don't
|
|
110
|
+
# advance past it), so without this it would be permanently unclickable,
|
|
111
|
+
# stranding a user who navigated back from it;
|
|
112
|
+
# - every other step links once visited. Forward jumps to unvisited steps
|
|
113
|
+
# stay disallowed.
|
|
114
|
+
def clickable?(step, state)
|
|
115
|
+
return false if state == :current
|
|
116
|
+
return @visited.any? if step.review?
|
|
117
|
+
@visited.include?(step.key.to_s)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Wizard
|
|
6
|
+
# A tiny read-only display over the wizard's typed `data` snapshot, used by
|
|
7
|
+
# the review step's auto-summary (§2.5). Reuses the Plutonium display
|
|
8
|
+
# pipeline (`Display::Base` + its inferred-type builder + `field(...).wrapped`)
|
|
9
|
+
# so each field's label and value formatting match the rest of the app,
|
|
10
|
+
# rather than re-implementing value rendering.
|
|
11
|
+
class SummaryDisplay < Plutonium::UI::Display::Base
|
|
12
|
+
# @param object [Object] the wizard `data` snapshot.
|
|
13
|
+
# @param fields [Array<Symbol>] scalar field names to summarize.
|
|
14
|
+
# @param inputs [Hash] the step's input config ({name => {options:}}), so a
|
|
15
|
+
# field's declared `as:` informs the display component (e.g. a `:text`
|
|
16
|
+
# input renders via the markdown/text display tag).
|
|
17
|
+
def initialize(object, fields:, inputs: {}, **options)
|
|
18
|
+
options[:key] = :wizard
|
|
19
|
+
@summary_fields = fields
|
|
20
|
+
@summary_inputs = inputs
|
|
21
|
+
super(object, **options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def display_template
|
|
25
|
+
dl(class: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3") do
|
|
26
|
+
@summary_fields.each { |name| render_summary_field(name) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def render_summary_field(name)
|
|
33
|
+
input_options = @summary_inputs[name]&.dig(:options) || {}
|
|
34
|
+
field_options = input_options[:label] ? {label: input_options[:label]} : {}
|
|
35
|
+
|
|
36
|
+
render field(name, **field_options).wrapped do |f|
|
|
37
|
+
render instance_exec(f, &summary_tag_block(name))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Pick the display component the same way a resource display does — infer it
|
|
42
|
+
# from the value's TYPE (date, boolean, number, currency, …) instead of
|
|
43
|
+
# stringifying everything. The one override: an attachment field stages a
|
|
44
|
+
# string TOKEN, so inference can't tell it's an attachment — force it (the
|
|
45
|
+
# data object is decorated upstream to resolve the token to an attachment).
|
|
46
|
+
def summary_tag_block(name)
|
|
47
|
+
->(f) {
|
|
48
|
+
tag = Plutonium::Wizard::Attachments.field?(@summary_inputs[name]) ? :attachment : f.inferred_field_component
|
|
49
|
+
if tag.is_a?(Class)
|
|
50
|
+
f.send(:create_component, tag, tag.name.demodulize.underscore.sub(/component$/, "").to_sym)
|
|
51
|
+
else
|
|
52
|
+
f.send(:"#{tag}_tag")
|
|
53
|
+
end
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/plutonium/version.rb
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Decorates a step's typed `data` so ATTACHMENT fields READ as resolved,
|
|
6
|
+
# displayable attachments ({Attachments::Resolved}) rather than the raw staged
|
|
7
|
+
# token string — so the step form's `Uppy` preview rehydrates on Back/resume
|
|
8
|
+
# (it calls `.url`/`.signed_id`/`.filename`, which a bare token string can't
|
|
9
|
+
# answer). Every other field delegates unchanged.
|
|
10
|
+
#
|
|
11
|
+
# Read-only: the SUBMITTED value is still the token the hidden preview field
|
|
12
|
+
# re-posts (`Resolved#signed_id` == the original token), so staging is
|
|
13
|
+
# unaffected.
|
|
14
|
+
class AttachmentData < SimpleDelegator
|
|
15
|
+
# Wrap only when the step actually has an attachment field — otherwise return
|
|
16
|
+
# the data untouched, so the overwhelming majority of steps are unaffected.
|
|
17
|
+
def self.wrap(data, step)
|
|
18
|
+
attachment_fields = step.inputs.select { |_name, config| Attachments.field?(config) }
|
|
19
|
+
attachment_fields.any? ? new(data, attachment_fields) : data
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(data, attachment_fields)
|
|
23
|
+
super(data)
|
|
24
|
+
attachment_fields.each do |name, config|
|
|
25
|
+
multiple = config.dig(:options, :multiple)
|
|
26
|
+
define_singleton_method(name) do
|
|
27
|
+
resolved = Attachments.resolve(__getobj__.public_send(name))
|
|
28
|
+
multiple ? resolved : resolved.first
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Masquerade as the wrapped object's class so phlexi still infers field
|
|
34
|
+
# affordances (required marker, maxlength, …) from its validators — it reads
|
|
35
|
+
# `object.class.validators_on(...)`, and a `SimpleDelegator` would otherwise
|
|
36
|
+
# report its own class and drop every marker on the step.
|
|
37
|
+
def class
|
|
38
|
+
__getobj__.class
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Bridges a wizard's staged attachment value to the displayable attachment the
|
|
6
|
+
# `Uppy` input and `Display::Components::Attachment` render — **model-free and
|
|
7
|
+
# backend-agnostic**, for a bare `attribute :photo, :string` + `input :photo,
|
|
8
|
+
# as: :uppy, direct_upload: true` field (no `using:` model needed).
|
|
9
|
+
#
|
|
10
|
+
# A wizard stages plain strings, so an attachment field holds its backend's own
|
|
11
|
+
# direct-upload token: ActiveStorage's **signed_id** (an opaque signed string)
|
|
12
|
+
# or active_shrine/Shrine's **cached-file data** (a JSON object). Those are the
|
|
13
|
+
# only two shapes, and they're distinguishable — a Shrine token parses as JSON,
|
|
14
|
+
# an AS signed_id doesn't — so we revive each through its own backend with no
|
|
15
|
+
# model and no per-field configuration.
|
|
16
|
+
#
|
|
17
|
+
# Resolution is **display-only**: staging and `execute` (which assigns the token
|
|
18
|
+
# straight to a model attachment — both AS and active_shrine accept it) never
|
|
19
|
+
# call it. The two backends' native objects answer DIFFERENT method names
|
|
20
|
+
# (`filename`/`content_type` vs `original_filename`/`mime_type`), so a resolved
|
|
21
|
+
# token is wrapped in {Resolved}, a uniform view exposing exactly what the
|
|
22
|
+
# display + preview components call. See the wizard-attachments design spec.
|
|
23
|
+
module Attachments
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Resolve a staged attachment token (or array of them) into uniform
|
|
27
|
+
# {Resolved} view(s).
|
|
28
|
+
#
|
|
29
|
+
# @param value [String, Array, nil] the staged token(s).
|
|
30
|
+
# @return [Array<Resolved>] resolved attachments; blank, tampered, or
|
|
31
|
+
# unrecognized tokens are dropped (never raised), so a bad token can't 500
|
|
32
|
+
# the form or the review.
|
|
33
|
+
def resolve(value)
|
|
34
|
+
Array(value).filter_map { |token| resolve_token(token) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether a step input renders as an attachment (its `as:` is a file alias),
|
|
38
|
+
# so its staged token should be resolved for display. Keys off the form's
|
|
39
|
+
# canonical file-input alias set, so the two never drift.
|
|
40
|
+
def field?(input_options)
|
|
41
|
+
as = input_options&.dig(:options, :as) || input_options&.dig(:as)
|
|
42
|
+
Plutonium::UI::Form::Base::Builder::FILE_INPUT_TYPES.include?(as&.to_sym)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# SERVER-SIDE staging: turn a submitted attachment value into a token string
|
|
46
|
+
# to stage in `data`, minting one from an uploaded file when needed.
|
|
47
|
+
#
|
|
48
|
+
# Handles every shape a step POST can carry for an attachment field:
|
|
49
|
+
# - an already-minted token String (direct upload, or the hidden preview field
|
|
50
|
+
# on re-submit) → kept verbatim;
|
|
51
|
+
# - an uploaded file (IO-like) → uploaded to the backend's cache, returning its
|
|
52
|
+
# token (an AS signed_id, or Shrine cached-file JSON);
|
|
53
|
+
# - blank / no selection → nil (the caller drops the key so the previously
|
|
54
|
+
# staged token survives a Back/re-submit);
|
|
55
|
+
# - an Array (multiple) → each element mapped, blanks dropped.
|
|
56
|
+
#
|
|
57
|
+
# @param backend [Symbol, nil] per-field override; nil → the configured default.
|
|
58
|
+
# @param uploader [Class, String, nil] a Shrine uploader to cache through
|
|
59
|
+
# (`:shrine` backend only) — its cache-stage plugins (mime/dimension
|
|
60
|
+
# extraction, `generate_location`, validations) run instead of base Shrine's.
|
|
61
|
+
# The minted token stays uploader-agnostic, so display + `execute` promotion
|
|
62
|
+
# are unaffected. Ignored shape for ActiveStorage (raises if given).
|
|
63
|
+
def stage_upload(value, backend: nil, uploader: nil)
|
|
64
|
+
if value.is_a?(Array)
|
|
65
|
+
value.filter_map { |v| stage_upload(v, backend:, uploader:) }.presence
|
|
66
|
+
elsif value.is_a?(String)
|
|
67
|
+
value.presence
|
|
68
|
+
elsif value.respond_to?(:read)
|
|
69
|
+
upload_to_cache(value, backend || attachment_backend, uploader:)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The default server-side staging backend: the configured one, else
|
|
74
|
+
# auto-detected (active_shrine loaded → Shrine, else ActiveStorage).
|
|
75
|
+
def attachment_backend
|
|
76
|
+
Plutonium.configuration.wizards.attachment_backend ||
|
|
77
|
+
(defined?(ActiveShrine) ? :shrine : :active_storage)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Run the EFFECTIVE Shrine uploader's attacher validations against a staged
|
|
81
|
+
# token (or array of them), returning the validation messages — so a file that
|
|
82
|
+
# violates the uploader's `validate_*` rules is rejected at the STEP (stage
|
|
83
|
+
# phase), not deferred to `execute`'s model assignment.
|
|
84
|
+
#
|
|
85
|
+
# The effective uploader is the field's `uploader:` if given, else base
|
|
86
|
+
# `Shrine` — both of which may carry `Attacher.validate` rules. Returns `[]`
|
|
87
|
+
# when the field isn't Shrine-backed (ActiveStorage has no attacher here), when
|
|
88
|
+
# nothing is staged, or when the effective uploader declares no validations.
|
|
89
|
+
#
|
|
90
|
+
# @param value [String, Array, nil] the staged token(s).
|
|
91
|
+
# @param backend [Symbol, nil] per-field override; nil → the configured default.
|
|
92
|
+
# @param uploader [Class, String, nil] the field's `uploader:` option.
|
|
93
|
+
# @return [Array<String>] validation messages (empty ⇒ valid).
|
|
94
|
+
def validation_errors(value, backend: nil, uploader: nil)
|
|
95
|
+
return [] unless (backend || attachment_backend).to_sym == :shrine
|
|
96
|
+
|
|
97
|
+
klass = shrine_uploader(uploader)
|
|
98
|
+
# Shrine's `validation` plugin is OPTIONAL — without it (or `validation_helpers`)
|
|
99
|
+
# the Attacher has no `#errors` and nothing to enforce. Detect it up front so a
|
|
100
|
+
# plugin-less app is a clean no-op, not a per-step rescued NoMethodError.
|
|
101
|
+
return [] unless klass::Attacher.method_defined?(:errors)
|
|
102
|
+
|
|
103
|
+
Array(value).flat_map { |token| token_validation_errors(klass, token) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Validate one cached token through an uploader's attacher. A broad rescue
|
|
107
|
+
# (like {resolve_token}) — a tampered/expired token shouldn't 500 the step; it
|
|
108
|
+
# surfaces at `execute` instead, where it's caught as a RecordInvalid.
|
|
109
|
+
def token_validation_errors(uploader_class, token)
|
|
110
|
+
return [] if token.blank?
|
|
111
|
+
|
|
112
|
+
attacher = uploader_class::Attacher.new
|
|
113
|
+
attacher.assign(token)
|
|
114
|
+
Array(attacher.errors)
|
|
115
|
+
rescue => e
|
|
116
|
+
Rails.logger.warn { "[Plutonium::Wizard] attachment validation skipped: #{e.class}: #{e.message}" }
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
private_class_method :token_validation_errors
|
|
120
|
+
|
|
121
|
+
# Upload a file to the backend's CACHE and return its re-postable token. The
|
|
122
|
+
# file lives in cache until `execute` assigns the token to a real attachment
|
|
123
|
+
# (which promotes it); an abandoned upload is reaped by the backend's own
|
|
124
|
+
# unattached-cache cleanup.
|
|
125
|
+
def upload_to_cache(file, backend, uploader: nil)
|
|
126
|
+
case backend.to_sym
|
|
127
|
+
when :shrine
|
|
128
|
+
shrine_uploader(uploader).upload(file, :cache).to_json
|
|
129
|
+
when :active_storage
|
|
130
|
+
raise ArgumentError, "input `uploader:` is only supported for the :shrine backend" if uploader
|
|
131
|
+
ActiveStorage::Blob.create_and_upload!(
|
|
132
|
+
io: file, filename: file.original_filename, content_type: file.content_type
|
|
133
|
+
).signed_id
|
|
134
|
+
else
|
|
135
|
+
raise ArgumentError, "unknown wizard attachment backend: #{backend.inspect}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
private_class_method :upload_to_cache
|
|
139
|
+
|
|
140
|
+
# Resolve an `uploader:` option to the Shrine uploader class to cache through.
|
|
141
|
+
# nil → base `Shrine`; a class is used as-is; a String/Symbol is constantized.
|
|
142
|
+
# Anything that isn't a Shrine subclass is a configuration error (fail loud).
|
|
143
|
+
def shrine_uploader(uploader)
|
|
144
|
+
return Shrine if uploader.nil?
|
|
145
|
+
|
|
146
|
+
klass = uploader.is_a?(Class) ? uploader : uploader.to_s.safe_constantize
|
|
147
|
+
unless klass.is_a?(Class) && klass <= Shrine
|
|
148
|
+
raise ArgumentError, "input `uploader:` must be a Shrine uploader class, got #{uploader.inspect}"
|
|
149
|
+
end
|
|
150
|
+
klass
|
|
151
|
+
end
|
|
152
|
+
private_class_method :shrine_uploader
|
|
153
|
+
|
|
154
|
+
# Revive one token through whichever backend owns it, wrapped in {Resolved}. A
|
|
155
|
+
# broad rescue is warranted here (unlike elsewhere): the token is arbitrary,
|
|
156
|
+
# user-supplied input reconstituted at a render boundary, and the two backends
|
|
157
|
+
# raise different error classes for a tampered/expired token — none of which
|
|
158
|
+
# should take down the page.
|
|
159
|
+
def resolve_token(token)
|
|
160
|
+
return if token.blank?
|
|
161
|
+
|
|
162
|
+
source = shrine_uploaded_file(token) || active_storage_blob(token)
|
|
163
|
+
source && Resolved.new(source, token)
|
|
164
|
+
rescue => e
|
|
165
|
+
Rails.logger.warn { "[Plutonium::Wizard] could not resolve attachment token: #{e.class}: #{e.message}" }
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
private_class_method :resolve_token
|
|
169
|
+
|
|
170
|
+
# A Shrine cached-file token is JSON (`{"id":…,"storage":"cache",…}`); an AS
|
|
171
|
+
# signed_id isn't. Parse-success → Shrine materializes it from the globally
|
|
172
|
+
# registered storages (no model, no per-field uploader needed for a `.url`).
|
|
173
|
+
def shrine_uploaded_file(token)
|
|
174
|
+
return unless defined?(Shrine)
|
|
175
|
+
|
|
176
|
+
data = begin
|
|
177
|
+
JSON.parse(token)
|
|
178
|
+
rescue JSON::ParserError, TypeError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
return unless data.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
Shrine.uploaded_file(data)
|
|
184
|
+
end
|
|
185
|
+
private_class_method :shrine_uploaded_file
|
|
186
|
+
|
|
187
|
+
def active_storage_blob(token)
|
|
188
|
+
ActiveStorage::Blob.find_signed(token) if defined?(ActiveStorage::Blob)
|
|
189
|
+
end
|
|
190
|
+
private_class_method :active_storage_blob
|
|
191
|
+
|
|
192
|
+
# A uniform view over a resolved attachment so the review display + the uppy
|
|
193
|
+
# preview don't care whether the source is an ActiveStorage `Blob`
|
|
194
|
+
# (`filename`/`content_type`/`representable?`) or a Shrine `UploadedFile`
|
|
195
|
+
# (`original_filename`/`mime_type`, none of the AS-only methods). Exposes
|
|
196
|
+
# exactly what those components call.
|
|
197
|
+
class Resolved
|
|
198
|
+
# @param source the backend object (AS Blob or Shrine UploadedFile).
|
|
199
|
+
# @param token [String] the ORIGINAL staged token — what the hidden preview
|
|
200
|
+
# field re-posts to preserve the upload across a Back/re-submit, and what
|
|
201
|
+
# `execute` assigns to the model attachment.
|
|
202
|
+
def initialize(source, token)
|
|
203
|
+
@source = source
|
|
204
|
+
@token = token
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# `url` is lazy (called at render, inside a request, where AS url options
|
|
208
|
+
# exist) — never eager at resolve time.
|
|
209
|
+
def url(*args) = @source.url(*args)
|
|
210
|
+
|
|
211
|
+
# The re-postable token, surfaced under the name the uppy input reads.
|
|
212
|
+
def signed_id = @token
|
|
213
|
+
|
|
214
|
+
def filename = (@source.try(:filename) || @source.try(:original_filename)).to_s
|
|
215
|
+
|
|
216
|
+
def content_type = @source.try(:content_type) || @source.try(:mime_type)
|
|
217
|
+
|
|
218
|
+
def representable? = @source.try(:representable?) || content_type.to_s.start_with?("image/")
|
|
219
|
+
|
|
220
|
+
def extension = @source.try(:extension).presence || File.extname(filename).delete(".").presence
|
|
221
|
+
|
|
222
|
+
def present? = true
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|