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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# ActiveRecord model backing the +plutonium_wizard_sessions+ table.
|
|
6
|
+
#
|
|
7
|
+
# Identity is the derived {InstanceKey} digest stored in +instance_key+; the
|
|
8
|
+
# polymorphic owner/anchor/scope refs exist for listing and rebuilding context,
|
|
9
|
+
# not for identity. At-rest encryption (the wizard's +encrypt_data+ opt-in) is
|
|
10
|
+
# applied by {Store::ActiveRecord} as a self-describing envelope in the +data+
|
|
11
|
+
# column, not statically here — so this model stays a plain schema mapping.
|
|
12
|
+
class Session < ActiveRecord::Base
|
|
13
|
+
self.table_name = "plutonium_wizard_sessions"
|
|
14
|
+
|
|
15
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
16
|
+
belongs_to :anchor, polymorphic: true, optional: true
|
|
17
|
+
belongs_to :scope, polymorphic: true, optional: true
|
|
18
|
+
|
|
19
|
+
enum :status,
|
|
20
|
+
{in_progress: "in_progress", completing: "completing", completed: "completed"},
|
|
21
|
+
prefix: true
|
|
22
|
+
|
|
23
|
+
# How long a row may sit in +completing+ before the sweep treats it as a
|
|
24
|
+
# CRASHED finalize and reaps it. A healthy finalize flips to +completing+,
|
|
25
|
+
# runs +execute+, and completes/clears the row within seconds — but +execute+
|
|
26
|
+
# runs OUTSIDE the completion lock and does not bump +expires_at+, so a sweep
|
|
27
|
+
# firing mid-finalize must NOT cancel it (that would destroy the run's
|
|
28
|
+
# tracked records out from under the in-flight +execute+, §6.2). The grace
|
|
29
|
+
# window distinguishes a finalize that is still running (recent +updated_at+)
|
|
30
|
+
# from one that crashed (stale +updated_at+). Generous on purpose.
|
|
31
|
+
COMPLETING_GRACE = 15.minutes
|
|
32
|
+
|
|
33
|
+
# Idle rows eligible for the abandonment sweep, by status:
|
|
34
|
+
#
|
|
35
|
+
# - +in_progress+ — abandoned: a concrete +expires_at+ (cleanup_after) that
|
|
36
|
+
# has passed. Rows with a null +expires_at+ (cleanup_after :never) are never
|
|
37
|
+
# swept.
|
|
38
|
+
# - +completing+ — a finalize that CRASHED mid-flight: it has been
|
|
39
|
+
# +completing+ longer than {COMPLETING_GRACE} (its +updated_at+, stamped
|
|
40
|
+
# when it entered +completing+, is older than now - grace). This keeps the
|
|
41
|
+
# sweep from racing an +execute+ that is still running (§6.2).
|
|
42
|
+
#
|
|
43
|
+
# +completed+ rows are never swept.
|
|
44
|
+
scope :sweepable, ->(now, completing_grace: COMPLETING_GRACE) {
|
|
45
|
+
in_progress =
|
|
46
|
+
status_in_progress.where.not(expires_at: nil).where(expires_at: ..now)
|
|
47
|
+
crashed_completing =
|
|
48
|
+
status_completing.where(updated_at: ..(now - completing_grace))
|
|
49
|
+
in_progress.or(crashed_completing)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# In-memory snapshot of one wizard instance's stored state, exchanged between
|
|
6
|
+
# the {Store} and the runner. Independent of any persistence backend.
|
|
7
|
+
#
|
|
8
|
+
# +data+ and +persisted+ default to an empty hash, +visited+ to an empty
|
|
9
|
+
# array, so callers never have to nil-check them.
|
|
10
|
+
State = Struct.new(
|
|
11
|
+
:wizard,
|
|
12
|
+
:instance_key,
|
|
13
|
+
:current_step,
|
|
14
|
+
:status,
|
|
15
|
+
:data,
|
|
16
|
+
:persisted,
|
|
17
|
+
:visited,
|
|
18
|
+
:owner,
|
|
19
|
+
:anchor,
|
|
20
|
+
:scope,
|
|
21
|
+
:token,
|
|
22
|
+
:engine,
|
|
23
|
+
# The optimistic-merge version the row was read at (nil for a state that was
|
|
24
|
+
# never read from a row, i.e. a fresh run). The store uses it to detect a
|
|
25
|
+
# concurrent write and merge instead of clobbering (§6.2).
|
|
26
|
+
:lock_version
|
|
27
|
+
) do
|
|
28
|
+
def data = self[:data] || {}
|
|
29
|
+
|
|
30
|
+
def persisted = self[:persisted] || {}
|
|
31
|
+
|
|
32
|
+
def visited = self[:visited] || []
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Metadata for one wizard step: its key, label, branching condition, captured
|
|
6
|
+
# field surface, per-step hooks, and the `using:` import marker (resolved in
|
|
7
|
+
# Task 3). A value object — holds no runtime state.
|
|
8
|
+
class Step
|
|
9
|
+
attr_reader :key, :label, :description, :condition, :fields,
|
|
10
|
+
:on_submit, :on_rollback, :using_spec
|
|
11
|
+
|
|
12
|
+
def initialize(key:, fields:, label: nil, description: nil, condition: nil,
|
|
13
|
+
on_submit: nil, on_rollback: nil, using_spec: nil)
|
|
14
|
+
@key = key
|
|
15
|
+
@label = label || key.to_s.humanize
|
|
16
|
+
@description = description
|
|
17
|
+
@condition = condition
|
|
18
|
+
@fields = fields
|
|
19
|
+
@on_submit = on_submit
|
|
20
|
+
@on_rollback = on_rollback
|
|
21
|
+
@using_spec = using_spec
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def review? = false
|
|
25
|
+
|
|
26
|
+
# The step's form sections (§7.1): inline `form_layout` wins, else the layout
|
|
27
|
+
# inherited from a `using:` source (filtered to imported fields), else nil.
|
|
28
|
+
# Resolved lazily so a `using:` import is only loaded when actually needed.
|
|
29
|
+
def form_layout = fields.form_layout_sections
|
|
30
|
+
|
|
31
|
+
# The effective attribute schema ({name => type}) contributed to the union
|
|
32
|
+
# `data` schema — a `using:` import composed with inline `attribute`
|
|
33
|
+
# declarations (inline wins on conflict, §2.4).
|
|
34
|
+
def attribute_schema = fields.attribute_schema
|
|
35
|
+
|
|
36
|
+
# The per-attribute options ({name => {default:, ...}}) contributed to the
|
|
37
|
+
# typed `data` snapshot, so e.g. `default:` applies (§2.6).
|
|
38
|
+
def attribute_options = fields.attribute_options
|
|
39
|
+
|
|
40
|
+
# The effective input config ({name => {options:, block:}}) — imported inputs
|
|
41
|
+
# composed with inline `input`/`field` declarations (inline wins).
|
|
42
|
+
def inputs = fields.inputs
|
|
43
|
+
|
|
44
|
+
# Inline `validates` declarations recorded for this step (raw [args, options]).
|
|
45
|
+
def validations = fields.validations
|
|
46
|
+
|
|
47
|
+
# Form-metadata validators contributed by a `using:` import ([args, options]
|
|
48
|
+
# pairs), replayed onto the typed data class alongside inline `validations`
|
|
49
|
+
# so imported fields surface required/length/etc. — without feeding the
|
|
50
|
+
# runner (which validates imports through the transient model).
|
|
51
|
+
def imported_form_validators = fields.imported_form_validators
|
|
52
|
+
|
|
53
|
+
# The imported validation runner ({attribute => [messages]} over a data
|
|
54
|
+
# slice), or nil when there's no `using:` import or `validate: false`.
|
|
55
|
+
def imported_validate_fn = fields.imported_validate_fn
|
|
56
|
+
|
|
57
|
+
# The structured inputs declared in this step ({name => {options:, block:}}).
|
|
58
|
+
def structured_inputs = fields.defined_structured_inputs
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Presents a wizard {Step} in the shape the existing resource-form pipeline
|
|
6
|
+
# (`Plutonium::UI::Form::Resource`) consumes from a definition (§7). The form
|
|
7
|
+
# renders a step exactly like a resource/interaction definition by reading:
|
|
8
|
+
#
|
|
9
|
+
# - `defined_fields` — empty; a step carries no separate field
|
|
10
|
+
# config, only `input`s.
|
|
11
|
+
# - `defined_inputs` — the step's merged inline + imported inputs
|
|
12
|
+
# (`{name => {options:, block:}}`).
|
|
13
|
+
# - `defined_structured_inputs` — the step's structured inputs.
|
|
14
|
+
# - `resolve_form_sections` — the step's resolved form layout (inline
|
|
15
|
+
# `form_layout` or one inherited from `using:`),
|
|
16
|
+
# normalized to ResolvedSections; nil → single
|
|
17
|
+
# grid.
|
|
18
|
+
#
|
|
19
|
+
# This is the seam that lets a wizard step ride the resource-form rendering
|
|
20
|
+
# path unchanged — seeded from the wizard's typed `data` (the form `object`),
|
|
21
|
+
# which is what makes resume/back rehydration (including repeater rows) work.
|
|
22
|
+
class StepAdapter
|
|
23
|
+
# Options on a file `input` that are consumed SERVER-SIDE by Wizard::Driving
|
|
24
|
+
# (from the raw step) to stage the upload — never form/HTML concerns. They must
|
|
25
|
+
# be stripped from what the form renders: Phlex rejects a Class-valued
|
|
26
|
+
# `uploader:` as an attribute, and `backend:` would otherwise leak as a stray
|
|
27
|
+
# attribute. Driving reads them off `step.inputs` directly, so removing them
|
|
28
|
+
# here is invisible to staging.
|
|
29
|
+
STAGING_ONLY_INPUT_OPTIONS = %i[backend uploader].freeze
|
|
30
|
+
|
|
31
|
+
def initialize(step)
|
|
32
|
+
@step = step
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :step
|
|
36
|
+
|
|
37
|
+
# The form's per-field config map. A step declares inputs, not fields, so
|
|
38
|
+
# there is no separate `field` config — return an empty map. The form merges
|
|
39
|
+
# `defined_fields[name]` (here {}) with `defined_inputs[name]`.
|
|
40
|
+
def defined_fields = {}
|
|
41
|
+
|
|
42
|
+
# `{name => {options:, block:}}` — inline + `using:`-imported inputs, with the
|
|
43
|
+
# server-side staging options stripped so they don't render as HTML attributes.
|
|
44
|
+
def defined_inputs
|
|
45
|
+
step.inputs.transform_values do |config|
|
|
46
|
+
options = config[:options]
|
|
47
|
+
next config if options.nil? || STAGING_ONLY_INPUT_OPTIONS.none? { |k| options.key?(k) }
|
|
48
|
+
|
|
49
|
+
config.merge(options: options.except(*STAGING_ONLY_INPUT_OPTIONS))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `{name => {options:, block:}}` — structured (single/repeater) inputs.
|
|
54
|
+
def defined_structured_inputs = step.structured_inputs
|
|
55
|
+
|
|
56
|
+
# The resource form never imports nested-resource inputs from a wizard step.
|
|
57
|
+
def defined_nested_inputs = {}
|
|
58
|
+
|
|
59
|
+
# Auto-detect submit-and-continue is disabled for wizards (the wizard owns
|
|
60
|
+
# its own Back/Next/Finish navigation).
|
|
61
|
+
def submit_and_continue = false
|
|
62
|
+
|
|
63
|
+
# Resolve the step's form layout into ordered ResolvedSections (the shape the
|
|
64
|
+
# resource form's `resolve_form_layout` expects), or nil for a single grid.
|
|
65
|
+
#
|
|
66
|
+
# The step's `form_layout` is either:
|
|
67
|
+
# - inline → an Array<FormLayout::Section> (unresolved), or
|
|
68
|
+
# - imported → an Array<FormLayout::ResolvedSection> (already resolved by
|
|
69
|
+
# the FieldImporter, filtered to imported fields).
|
|
70
|
+
# Normalize both to ResolvedSections claiming only currently-permitted fields.
|
|
71
|
+
def resolve_form_sections(resource_fields)
|
|
72
|
+
layout = step.form_layout
|
|
73
|
+
return nil if layout.blank?
|
|
74
|
+
|
|
75
|
+
resource_fields = resource_fields.map(&:to_sym)
|
|
76
|
+
return resolve_resolved_sections(layout, resource_fields) if layout.first.is_a?(Plutonium::Definition::FormLayout::ResolvedSection)
|
|
77
|
+
|
|
78
|
+
resolve_raw_sections(layout, resource_fields)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# An imported layout is already ResolvedSections; just restrict each section's
|
|
84
|
+
# fields to the currently-permitted set (and drop emptied non-ungrouped ones).
|
|
85
|
+
def resolve_resolved_sections(layout, resource_fields)
|
|
86
|
+
known = resource_fields.to_set
|
|
87
|
+
layout.filter_map do |resolved|
|
|
88
|
+
fields = resolved.fields.map(&:to_sym).select { |f| known.include?(f) }
|
|
89
|
+
next if fields.empty? && !resolved.section.ungrouped?
|
|
90
|
+
Plutonium::Definition::FormLayout::ResolvedSection.new(resolved.section, fields)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Raw inline Sections resolve exactly like a resource definition's layout
|
|
95
|
+
# (first-section-wins ownership; unlisted permitted fields fall into a
|
|
96
|
+
# trailing ungrouped bucket) — so delegate to the canonical resolver rather
|
|
97
|
+
# than re-implement it.
|
|
98
|
+
def resolve_raw_sections(layout, resource_fields)
|
|
99
|
+
Plutonium::Definition::FormLayout.resolve_sections(layout, resource_fields)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
module Store
|
|
6
|
+
# Shipped store, backed by the +plutonium_wizard_sessions+ table via {Session}.
|
|
7
|
+
class ActiveRecord < Base
|
|
8
|
+
# A +data+ blob encrypted at rest (§8.1, the wizard's +encrypt_data+ opt-in)
|
|
9
|
+
# is stored as a SELF-DESCRIBING envelope INSIDE the jsonb column:
|
|
10
|
+
# +{ "_enc" => "<ciphertext>" }+. The row therefore decrypts based on its own
|
|
11
|
+
# shape, independent of the wizard's CURRENT +encrypt_data?+ (which may have
|
|
12
|
+
# been toggled after the row was written). Only +data+ (the step field values)
|
|
13
|
+
# is encrypted; +tracked_records+ holds record GIDs and stays in clear.
|
|
14
|
+
ENCRYPTED_ENVELOPE_KEY = "_enc"
|
|
15
|
+
|
|
16
|
+
def read(instance_key)
|
|
17
|
+
row = Session.find_by(instance_key: instance_key)
|
|
18
|
+
row && to_state(row)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Upsert the run's state. Concurrency-safe (§6.2): two requests can write the
|
|
22
|
+
# same run (double-submit, two tabs, the first-step create race). A blind
|
|
23
|
+
# overwrite would let the later writer clobber the earlier one's staged data
|
|
24
|
+
# (last-writer-wins). Instead:
|
|
25
|
+
#
|
|
26
|
+
# - **Create** (no row yet): insert. If a concurrent create won the unique
|
|
27
|
+
# +instance_key+ index (RecordNotUnique), fall through to the merge path.
|
|
28
|
+
# - **Update** (row exists): take a row lock, then compare the version the
|
|
29
|
+
# caller's +state+ was read at against the row's CURRENT version. Equal →
|
|
30
|
+
# no concurrent writer, write +state+ verbatim (honors this request's
|
|
31
|
+
# prunes/deletions). Differs (or the caller never read a version — it lost
|
|
32
|
+
# a create race) → a concurrent advance committed; call the +merge+ block
|
|
33
|
+
# with the latest committed {State} so the two sides are combined, and
|
|
34
|
+
# write the result. The lock serializes writers; the version check keeps
|
|
35
|
+
# the merge off the normal single-writer path.
|
|
36
|
+
#
|
|
37
|
+
# +merge+ is optional — a blockless caller keeps last-writer-wins (the
|
|
38
|
+
# store-contract callers that don't model concurrency). The runner always
|
|
39
|
+
# passes one.
|
|
40
|
+
def write(instance_key, state, cleanup_after:, &merge)
|
|
41
|
+
row = Session.find_or_initialize_by(instance_key: instance_key)
|
|
42
|
+
|
|
43
|
+
if row.new_record?
|
|
44
|
+
assign_row(row, state, cleanup_after)
|
|
45
|
+
begin
|
|
46
|
+
row.save!
|
|
47
|
+
return to_state(row)
|
|
48
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
49
|
+
# Lost the create race — the row now exists. Re-fetch and merge.
|
|
50
|
+
row = Session.find_by!(instance_key: instance_key)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
write_locked(row, state, cleanup_after, merge)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def complete(instance_key)
|
|
58
|
+
row = Session.find_by!(instance_key: instance_key)
|
|
59
|
+
row.update!(status: "completed", completed_at: Time.current, data: {}, tracked_records: {}, visited: [])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear(instance_key)
|
|
63
|
+
Session.where(instance_key: instance_key).delete_all
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def completed?(instance_key:)
|
|
67
|
+
Session.status_completed.where(instance_key: instance_key).exists?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Update an existing row under a row lock, merging when a concurrent writer
|
|
73
|
+
# moved it past the version the caller read. Returns the written {State}
|
|
74
|
+
# (carrying the bumped +lock_version+ so the caller's next write is current).
|
|
75
|
+
def write_locked(row, state, cleanup_after, merge)
|
|
76
|
+
row.with_lock do
|
|
77
|
+
final = (merge && stale_for_merge?(row, state)) ? merge.call(to_state(row)) : state
|
|
78
|
+
assign_row(row, final, cleanup_after)
|
|
79
|
+
row.save!
|
|
80
|
+
end
|
|
81
|
+
to_state(row)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Whether the row has moved since the caller read it (so a verbatim write
|
|
85
|
+
# would clobber a concurrent advance): the caller never read a version (nil
|
|
86
|
+
# — it thought it was creating, i.e. lost a create race), or its version is
|
|
87
|
+
# behind the row's current one.
|
|
88
|
+
def stale_for_merge?(row, state)
|
|
89
|
+
state.lock_version.nil? || state.lock_version != row.lock_version
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Copy a {State} onto the row's columns. Never touches +lock_version+ —
|
|
93
|
+
# ActiveRecord manages it (the WHERE-clause check + increment on save).
|
|
94
|
+
def assign_row(row, state, cleanup_after)
|
|
95
|
+
row.wizard = state.wizard
|
|
96
|
+
row.current_step = state.current_step
|
|
97
|
+
row.status ||= "in_progress"
|
|
98
|
+
row.data = encode_data(state.data, state.wizard)
|
|
99
|
+
row.tracked_records = state.persisted
|
|
100
|
+
row.visited = state.visited
|
|
101
|
+
row.owner = state.owner
|
|
102
|
+
row.anchor = state.anchor
|
|
103
|
+
row.scope = state.scope
|
|
104
|
+
row.token = state.token
|
|
105
|
+
row.engine = state.engine
|
|
106
|
+
row.expires_at = cleanup_after ? Time.current + cleanup_after : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_state(row)
|
|
110
|
+
State.new(
|
|
111
|
+
wizard: row.wizard,
|
|
112
|
+
instance_key: row.instance_key,
|
|
113
|
+
current_step: row.current_step,
|
|
114
|
+
status: row.status,
|
|
115
|
+
data: decode_data(row.data, row.wizard),
|
|
116
|
+
persisted: row.tracked_records,
|
|
117
|
+
visited: row.visited,
|
|
118
|
+
owner: row.owner,
|
|
119
|
+
anchor: row.anchor,
|
|
120
|
+
scope: row.scope,
|
|
121
|
+
token: row.token,
|
|
122
|
+
engine: row.engine,
|
|
123
|
+
lock_version: row.lock_version
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Encrypt the run's field data at rest when the wizard opts in via
|
|
128
|
+
# `encrypt_data`; a non-encrypting wizard stores the plain hash. Wrapped in
|
|
129
|
+
# a self-describing {ENCRYPTED_ENVELOPE_KEY} envelope (see the class note).
|
|
130
|
+
def encode_data(data, wizard_name)
|
|
131
|
+
return data unless encrypting?(wizard_name)
|
|
132
|
+
|
|
133
|
+
cipher = with_encryption_context(wizard_name) do
|
|
134
|
+
::ActiveRecord::Encryption.encryptor.encrypt(JSON.generate(data))
|
|
135
|
+
end
|
|
136
|
+
{ENCRYPTED_ENVELOPE_KEY => cipher}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Reverse of {#encode_data}: an envelope is decrypted from the row's SHAPE
|
|
140
|
+
# (not the wizard's current flag); any other value is a plain, never-
|
|
141
|
+
# encrypted blob and is returned as-is.
|
|
142
|
+
def decode_data(stored, wizard_name)
|
|
143
|
+
return stored unless stored.is_a?(Hash) && stored.key?(ENCRYPTED_ENVELOPE_KEY)
|
|
144
|
+
|
|
145
|
+
clear = with_encryption_context(wizard_name) do
|
|
146
|
+
::ActiveRecord::Encryption.encryptor.decrypt(stored[ENCRYPTED_ENVELOPE_KEY])
|
|
147
|
+
end
|
|
148
|
+
JSON.parse(clear)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Whether the wizard opted into at-rest encryption. A name that doesn't
|
|
152
|
+
# resolve to a loaded class (e.g. a renamed/removed wizard, or a synthetic
|
|
153
|
+
# store-test name) can carry no `encrypt_data` declaration to honour, so it
|
|
154
|
+
# is treated as clear — existing rows decode by SHAPE regardless.
|
|
155
|
+
def encrypting?(wizard_name)
|
|
156
|
+
wizard_name.to_s.safe_constantize&.encrypt_data? || false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Run an encrypt/decrypt, translating ActiveRecord's lazy, context-free
|
|
160
|
+
# configuration error into one that names the wizard and points at the fix —
|
|
161
|
+
# `encrypt_data` is opt-in, so a missing key set is an author error, not a
|
|
162
|
+
# runtime surprise to swallow.
|
|
163
|
+
def with_encryption_context(wizard_name)
|
|
164
|
+
yield
|
|
165
|
+
rescue ::ActiveRecord::Encryption::Errors::Configuration => e
|
|
166
|
+
raise ::ActiveRecord::Encryption::Errors::Configuration,
|
|
167
|
+
"#{wizard_name} declares `encrypt_data` but ActiveRecord encryption is not " \
|
|
168
|
+
"configured (set active_record.encryption.{primary_key,deterministic_key," \
|
|
169
|
+
"key_derivation_salt}). Original error: #{e.message}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
module Store
|
|
6
|
+
# Storage port for wizard sessions. Adapters exchange {State} value objects
|
|
7
|
+
# and are keyed by the derived {InstanceKey} digest.
|
|
8
|
+
class Base
|
|
9
|
+
# @param instance_key [String]
|
|
10
|
+
# @return [State, nil]
|
|
11
|
+
def read(instance_key) = raise NotImplementedError
|
|
12
|
+
|
|
13
|
+
# Upsert by +instance_key+. Stamps +expires_at = now + cleanup_after+
|
|
14
|
+
# (nil cleanup_after → null expiry).
|
|
15
|
+
#
|
|
16
|
+
# @param instance_key [String]
|
|
17
|
+
# @param state [State]
|
|
18
|
+
# @param cleanup_after [ActiveSupport::Duration, nil]
|
|
19
|
+
# @return [State]
|
|
20
|
+
def write(instance_key, state, cleanup_after:) = raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
# Mark completed: status "completed", stamp completed_at, null data/persisted.
|
|
23
|
+
#
|
|
24
|
+
# @param instance_key [String]
|
|
25
|
+
def complete(instance_key) = raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
# Delete the row.
|
|
28
|
+
#
|
|
29
|
+
# @param instance_key [String]
|
|
30
|
+
def clear(instance_key) = raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
# One-time completion check (§4.3 / §9): does a `completed` row exist at
|
|
33
|
+
# this instance_key? Identity is the digest, so the caller recomputes the
|
|
34
|
+
# wizard's instance_key (concurrency_key + folded tenant) and asks here.
|
|
35
|
+
#
|
|
36
|
+
# @param instance_key [String]
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def completed?(instance_key:) = raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
module Store
|
|
6
|
+
# In-memory store for fast, DB-free unit tests (and a template for future
|
|
7
|
+
# adapters). Mirrors {ActiveRecord}'s observable behavior.
|
|
8
|
+
class Memory < Base
|
|
9
|
+
def initialize
|
|
10
|
+
@rows = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read(instance_key)
|
|
14
|
+
@rows[instance_key]&.dup
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def write(instance_key, state, cleanup_after:)
|
|
18
|
+
state = state.dup
|
|
19
|
+
state.instance_key = instance_key
|
|
20
|
+
state.status ||= "in_progress"
|
|
21
|
+
@rows[instance_key] = state
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def complete(instance_key)
|
|
25
|
+
state = @rows.fetch(instance_key)
|
|
26
|
+
state.status = "completed"
|
|
27
|
+
state.data = {}
|
|
28
|
+
state.persisted = {}
|
|
29
|
+
state.visited = []
|
|
30
|
+
state
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear(instance_key)
|
|
34
|
+
@rows.delete(instance_key)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def completed?(instance_key:)
|
|
38
|
+
row = @rows[instance_key]
|
|
39
|
+
!!row && row.status == "completed"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# The abandonment sweep (§8.1). Reaps idle wizard sessions whose +expires_at+
|
|
6
|
+
# has passed (status +in_progress+ or +completing+ — the latter catches a
|
|
7
|
+
# finalize that crashed mid-flight, §6.2). For each row it builds a {Runner}
|
|
8
|
+
# and calls +cancel+, which runs the wizard's cleanup — each step's per-step
|
|
9
|
+
# +on_rollback+ (additive side-effect cleanup, if any) then the engine's
|
|
10
|
+
# always-on destroy of every tracked record, in reverse order — and then
|
|
11
|
+
# deletes the row.
|
|
12
|
+
#
|
|
13
|
+
# This is **load-bearing for save-as-you-go wizards**: for +execute+-only
|
|
14
|
+
# wizards an unscheduled sweep merely leaves stale session rows (harmless), but
|
|
15
|
+
# for +on_submit+ wizards the sweep is the only thing that cleans up abandoned
|
|
16
|
+
# partial domain records. Hosts must schedule it (a periodic job / rake task).
|
|
17
|
+
#
|
|
18
|
+
# The job is idempotent and safe to re-run: a row already cleared is skipped,
|
|
19
|
+
# and an unconstantizable wizard class is skipped while the row is still reaped.
|
|
20
|
+
# +completed+ rows are never touched (the +sweepable+ scope excludes them).
|
|
21
|
+
class SweepJob < ActiveJob::Base
|
|
22
|
+
def perform(now: Time.current)
|
|
23
|
+
store = Store::ActiveRecord.new
|
|
24
|
+
|
|
25
|
+
Session.sweepable(now).find_each do |row|
|
|
26
|
+
sweep_row(row, store, now)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def sweep_row(row, store, now)
|
|
33
|
+
# Re-check the row is STILL sweepable under a fresh read. The finalize we may
|
|
34
|
+
# be racing (a `completing` row within, or just past, the grace window) could
|
|
35
|
+
# have completed — and a one-time wizard RETAINS its `completed` row as the
|
|
36
|
+
# gate marker. Without this re-check, the sweep could cancel+delete a row the
|
|
37
|
+
# finalize just completed, destroying that marker (un-gating the user) or
|
|
38
|
+
# racing the cleanup of its tracked records. A still-sweepable row is safe to
|
|
39
|
+
# reap; anything else (completed, cleared, or refreshed) is skipped.
|
|
40
|
+
return unless Session.sweepable(now).exists?(id: row.id)
|
|
41
|
+
|
|
42
|
+
wizard_class = row.wizard.safe_constantize
|
|
43
|
+
|
|
44
|
+
if wizard_class
|
|
45
|
+
# `cancel` runs the wizard's cleanup (each step's on_rollback, then the
|
|
46
|
+
# engine always destroys its tracked records) and then clears the row.
|
|
47
|
+
#
|
|
48
|
+
# Reconstruct the run's context from the row the sweep already trusts:
|
|
49
|
+
#
|
|
50
|
+
# - `current_user: row.owner` — a non-`anonymous` wizard's state is
|
|
51
|
+
# owner-scoped (§4.5), so with no owner the runner would mismatch, drop
|
|
52
|
+
# the loaded state, and cancel an EMPTY run, orphaning every `persist`'d
|
|
53
|
+
# record (the exact case the sweep exists for).
|
|
54
|
+
# - `current_scoped_entity: row.scope` — the wizard's `current_scoped_entity`
|
|
55
|
+
# is set from this argument (not the loaded state), so a tenant-aware
|
|
56
|
+
# `on_rollback` would otherwise run with a nil tenant.
|
|
57
|
+
#
|
|
58
|
+
# (The `anchor` needs no argument — the runner restores it from the loaded
|
|
59
|
+
# state.)
|
|
60
|
+
Runner.new(
|
|
61
|
+
wizard_class: wizard_class,
|
|
62
|
+
store: store,
|
|
63
|
+
instance_key: row.instance_key,
|
|
64
|
+
current_user: row.owner,
|
|
65
|
+
current_scoped_entity: row.scope
|
|
66
|
+
).cancel
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Safety net: an unconstantizable wizard never ran `cancel` (so the row is
|
|
70
|
+
# still present), and a `cancel` failure shouldn't leave the row behind.
|
|
71
|
+
# `clear` is a no-op when the row is already gone.
|
|
72
|
+
store.clear(row.instance_key)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "wizard/errors"
|
|
4
|
+
require_relative "wizard/configuration"
|
|
5
|
+
|
|
6
|
+
module Plutonium
|
|
7
|
+
# The Plutonium wizard subsystem: multi-step, DB-backed, data-capture wizards.
|
|
8
|
+
module Wizard
|
|
9
|
+
# The union `data` schema (§2.6) and the runner's inline validator both build
|
|
10
|
+
# anonymous ActiveModel classes from a step's `attribute_schema`. A `using:`
|
|
11
|
+
# import contributes the model's column types (e.g. `:text`), which
|
|
12
|
+
# ActiveModel's type registry doesn't know. Fall back to `:string` for any type
|
|
13
|
+
# the registry can't resolve so the snapshot/validator still builds — the
|
|
14
|
+
# staged value is stored/displayed as-is.
|
|
15
|
+
def self.safe_attribute_type(type)
|
|
16
|
+
ActiveModel::Type.lookup(type)
|
|
17
|
+
type
|
|
18
|
+
rescue ArgumentError
|
|
19
|
+
:string
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Compute a wizard run's identity digest (§4.1), shared by the runner/driving
|
|
23
|
+
# layer (which creates rows) and the gate (which recomputes the key) so the
|
|
24
|
+
# two are byte-identical — if they diverge, one-time gating silently breaks.
|
|
25
|
+
#
|
|
26
|
+
# A wizard with a `concurrency_key` is hashed over its resolved key value(s)
|
|
27
|
+
# (the tenant is folded in by {Base#concurrency_key_value}); otherwise it's
|
|
28
|
+
# hashed over the per-launch `wizard_token`.
|
|
29
|
+
#
|
|
30
|
+
# The wizard's `concurrency_key` resolver and tenancy fold run in a transient
|
|
31
|
+
# wizard instance seeded with the identity context. A resolver that references
|
|
32
|
+
# a missing context method raises a clear error.
|
|
33
|
+
#
|
|
34
|
+
# @return [String] the hex SHA256 instance_key
|
|
35
|
+
def self.compute_instance_key(wizard_class:, current_user:, current_scoped_entity:, anchor:, wizard_token:)
|
|
36
|
+
unless wizard_class.concurrency_key?
|
|
37
|
+
return InstanceKey.tokened(wizard_class.name, wizard_token)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
probe = wizard_class.new
|
|
41
|
+
probe.current_user = current_user
|
|
42
|
+
probe.current_scoped_entity = current_scoped_entity
|
|
43
|
+
probe.wizard_token = wizard_token
|
|
44
|
+
probe.anchor = anchor if wizard_class.anchored?
|
|
45
|
+
key_value = probe.concurrency_key_value
|
|
46
|
+
InstanceKey.concurrency(wizard_class.name, key_value)
|
|
47
|
+
rescue NameError => e
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"#{wizard_class.name}'s concurrency_key referenced a method that isn't " \
|
|
50
|
+
"available in this context (#{e.message}). Available: current_user, " \
|
|
51
|
+
"current_scoped_entity, anchor, wizard_token, or a host method."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The "continue where you left off" listing (§4.5): in-progress wizard runs
|
|
55
|
+
# for the current user, narrowed to the current tenant scope when the portal is
|
|
56
|
+
# entity-scoped, each enriched with the wizard's label/icon, current step (+
|
|
57
|
+
# label), updated_at, and a resolved resume_url (nil with a reason when a mount
|
|
58
|
+
# can't be resolved generically).
|
|
59
|
+
#
|
|
60
|
+
# This is the public, ergonomic API: like interactions, it takes the
|
|
61
|
+
# +view_context+ and derives the run owner and tenant scope from the controller
|
|
62
|
+
# it carries — `current_user` (the run owner) and `current_scoped_entity` (the
|
|
63
|
+
# tenant, when `scoped_to_entity?`; nil for a non-scoped portal). It delegates to
|
|
64
|
+
# {Resume.entries_for}, which does that derivation and builds the resume URLs in
|
|
65
|
+
# this portal.
|
|
66
|
+
#
|
|
67
|
+
# Plutonium::Wizard.in_progress_for(view_context)
|
|
68
|
+
# Plutonium::Wizard.in_progress_for(view_context, wizard: ConfigureCompanyWizard)
|
|
69
|
+
# Plutonium::Wizard.in_progress_for(view_context, anchor: company)
|
|
70
|
+
#
|
|
71
|
+
# +anchor:+ and +wizard:+ are OPTIONAL narrowing filters applied IN THE QUERY,
|
|
72
|
+
# before each row is enriched (resume-URL built, anchor loaded) — so filtering
|
|
73
|
+
# here is cheaper than `select`-ing the returned array, which enriches every row
|
|
74
|
+
# first. Use them for the per-record / per-wizard resume widgets (e.g. "does this
|
|
75
|
+
# company have an unfinished `configure` draft?"). They compose. Omit both for
|
|
76
|
+
# the full "continue where you left off" dashboard list.
|
|
77
|
+
#
|
|
78
|
+
# @param view_context [ActionView::Base] the current view context (as interactions take)
|
|
79
|
+
# @param anchor [ActiveRecord::Base, nil] narrow to runs anchored against this record
|
|
80
|
+
# @param wizard [Class, nil] narrow to runs of this wizard class
|
|
81
|
+
# @return [Array<Plutonium::Wizard::Resume::Entry>]
|
|
82
|
+
def self.in_progress_for(view_context, anchor: nil, wizard: nil)
|
|
83
|
+
Resume.entries_for(view_context, anchor: anchor, wizard: wizard)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|