Cross-cutting helpers for the projects module's LiveView layer.
Two surfaces live here:
Multilang form merge helpers
lang_data/2, merge_translations_attrs/3, in_flight_record/3,
normalize_datetime_local_attrs/2, maybe_switch_to_primary_on_error/3
— shared between ProjectFormLive, TaskFormLive, and
AssignmentFormLive. See each function's docstring for the contract.
Embed-mode helpers (PR follow-up to PR #6 audit)
assign_embed_state/2, navigate_or_open/2, close_or_navigate/2,
navigate_after_save/3, notify_deleted_or_navigate/4,
attach_open_embed_hook/1, embeddable_lv?/1, plus the
decode_embeddable_lv/1 and decode_session/1 decoders used by the
shared open_embed event handler.
When a host mounts an embedded LV with session["mode"] = "emit" +
session["pubsub_topic"] = topic, no push_navigate ever fires from
this module. Instead, UI-intent events are broadcast on the host's
topic so the host can render the requested LV inside a popup/drawer/
inline panel on the existing page — no URL change, no DOM replacement.
See dev_docs/embedding_emit.md for the full contract.
Summary
Functions
Reads the four embed-mode session keys, validates them, and assigns
them onto the socket. Call from every embeddable LV's mount/3 after
wrapper_class resolution.
Attaches the shared open_embed event handler to the socket via
Phoenix.LiveView.attach_hook/4. Call from every LV that uses
<.smart_link> (the conventional entry point is to chain it after
assign_embed_state/2 in mount/3).
Cancel / Back behaviour.
Decodes a stringified module name into an atom, validating that the
result is in embeddable_lvs/0. Used by the open_embed event
handler and by PopupHostLive's root_view decoder.
Decodes the phx-value-session JSON blob produced by <.smart_link>.
Returns {:ok, map} on success, :error on malformed JSON.
Returns true iff mod is in the embeddable whitelist.
The canonical list of LVs eligible for embed-mode mounting.
Render-time JSON encoding of an emit-target session for the
phx-value-session attribute on an open_embed button.
Returns the user's in-flight record by applying the current changeset.
Reads the translations field off the current changeset and returns
the sub-map for current_lang (or %{}).
Restores the Gettext locale in an embedded LiveView process.
When a save fails with errors on translatable primary fields, the
inline error renders on the primary tab — <.translatable_field>
suppresses errors on secondary tabs by design (it's the wrong field
to attach them to). If the user submitted from a secondary tab,
they'll see no visible change. This helper detects that case and
flips :current_lang back to :primary_language so the error is
immediately visible after the form re-renders.
Folds secondary-language form params into the in-flight record's
translations JSONB and preserves primary-language column values
that the current secondary-tab DOM didn't render.
Routes a form-save transition per the socket's :embed_mode.
Routes per :embed_mode. In navigate mode: push_navigate(to: opts[:to]).
In emit mode: broadcasts {:projects, :opened, %{lv, session, frame_ref}}
and returns the socket unchanged.
Normalises <input type="datetime-local"> form values to ISO 8601
strings Ecto's :utc_datetime cast accepts.
Emits {:projects, :deleted, %{kind, uuid, close: false, frame_ref}}
on the host topic when in emit mode, no-ops in navigate mode.
Delete-success path that navigates. In navigate mode:
push_navigate(to: fallback_path). In emit mode: broadcasts
{:projects, :deleted, %{kind, uuid, frame_ref}} and returns the
socket unchanged.
Builds the params map an apply_action/3 clause expects.
Resolves the live_action for the embedded mount path.
Functions
@spec assign_embed_state(Phoenix.LiveView.Socket.t(), map()) :: Phoenix.LiveView.Socket.t()
Reads the four embed-mode session keys, validates them, and assigns
them onto the socket. Call from every embeddable LV's mount/3 after
wrapper_class resolution.
Session keys read:
"mode"—"navigate"(default) or"emit""pubsub_topic"— string; required when mode is"emit""frame_ref"— opaque integer stamped byPopupHostLive(may be nil)
Socket assigns produced:
:embed_mode—:navigate | :emit:embed_pubsub_topic— string or nil:embed_frame_ref— integer or nil
Raises ArgumentError if mode == "emit" but pubsub_topic is missing
— fail-fast at mount rather than silently no-op every later emit call.
@spec attach_open_embed_hook(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
Attaches the shared open_embed event handler to the socket via
Phoenix.LiveView.attach_hook/4. Call from every LV that uses
<.smart_link> (the conventional entry point is to chain it after
assign_embed_state/2 in mount/3).
The hook intercepts phx-click="open_embed" events fired by
<.smart_link> in emit mode, validates the lv value against
embeddable_lvs/0, JSON-decodes the session value, and emits
:opened. Halts the event so the host LV's own handle_event/3
never sees it.
In navigate mode <.smart_link> renders a plain <.link navigate>
and no open_embed event ever fires — the hook is harmless then.
Decodes a stringified module name into an atom, validating that the
result is in embeddable_lvs/0. Used by the open_embed event
handler and by PopupHostLive's root_view decoder.
Accepts both forms hosts can plausibly write:
"Elixir.PhoenixKitProjects.Web.OverviewLive"— the fully- qualified atom string (whatAtom.to_string/1produces on a module, and what<.smart_link>puts on the wire)"PhoenixKitProjects.Web.OverviewLive"— the human-friendly form used in docs andPopupHostLive'sroot_viewsession example. Prepended withElixir.before lookup.
Returns :error if the resulting atom isn't in the whitelist (or
doesn't exist as an atom yet — String.to_existing_atom/1 raises,
which we trap).
Decodes the phx-value-session JSON blob produced by <.smart_link>.
Returns {:ok, map} on success, :error on malformed JSON.
Returns true iff mod is in the embeddable whitelist.
Used by PopupHostLive and the shared open_embed event handler
before passing a module atom to live_render or
String.to_existing_atom/1. Protects against hot-reload renames and
arbitrary-atom injection if the contract is ever wired to an
untrusted HTTP boundary.
@spec embeddable_lvs() :: [module()]
The canonical list of LVs eligible for embed-mode mounting.
Render-time JSON encoding of an emit-target session for the
phx-value-session attribute on an open_embed button.
Wrapped (non-bang Jason.encode/1) so a caller passing a struct or
atom value never crashes the whole view at render time — <.smart_link>
/ <.smart_menu_link> are the canonical navigation primitives and a
single bad payload would take down every button rendered on the page.
On failure we fall back to "{}": the click still fires, and the
target LV's fail-closed mount(:not_mounted_at_router, session, socket) clause (every embeddable LV has one) flashes "not found" and
closes the modal — the same shape as a deliberately empty session.
The warning surfaces the misuse in logs without the page crashing.
source is the calling component module, included in the log line so
the misuse is traceable to the offending button.
@spec in_flight_record(Phoenix.LiveView.Socket.t(), atom(), atom()) :: struct()
Returns the user's in-flight record by applying the current changeset.
When the user has been typing in a primary-tab field and switches to a
secondary tab, the server-side changeset already captures those primary
values from prior validate events. Re-using socket.assigns[:project]
(or :task, etc.) would lose them because that struct is the
pristine pre-form-edit version. Apply the changeset to get the
baseline that has both the primary fields AND the existing
translations the user has already typed.
@spec lang_data(Phoenix.HTML.Form.t(), String.t() | nil) :: map()
Reads the translations field off the current changeset and returns
the sub-map for current_lang (or %{}).
Used as the lang_data attr on <.translatable_field> so secondary
tabs see in-flight overrides — without this, switching between two
secondary tabs would lose unsaved edits.
@spec maybe_put_locale(map()) :: :ok | nil
Restores the Gettext locale in an embedded LiveView process.
When an LV is mounted via live_render/3, Phoenix spawns a new process
that does not inherit the parent's process dictionary. The active Gettext
locale is lost, so all translations fall back to the backend default
(English). Embedders can pass the current locale via
session["locale"]; calling this helper at the top of mount/3
reapplies it before any gettext/1 or L10n.current_content_lang/0
call runs.
Backward-compatible: when "locale" is absent, this is a no-op.
@spec maybe_switch_to_primary_on_error( Phoenix.LiveView.Socket.t(), Ecto.Changeset.t(), [atom()] ) :: Phoenix.LiveView.Socket.t()
When a save fails with errors on translatable primary fields, the
inline error renders on the primary tab — <.translatable_field>
suppresses errors on secondary tabs by design (it's the wrong field
to attach them to). If the user submitted from a secondary tab,
they'll see no visible change. This helper detects that case and
flips :current_lang back to :primary_language so the error is
immediately visible after the form re-renders.
translatable_fields is the list of DB column names (atoms) tracked
by the schema's translatable_fields/0 — e.g. [:name, :description].
Folds secondary-language form params into the in-flight record's
translations JSONB and preserves primary-language column values
that the current secondary-tab DOM didn't render.
primary_fields is the list of DB column names (as strings) that are
translatable — e.g. ["name", "description"] for Project,
["title", "description"] for Task. Same as
Project.translatable_fields/0 etc.
Normalises <input type="datetime-local"> form values to ISO 8601
strings Ecto's :utc_datetime cast accepts.
Browsers post YYYY-MM-DDTHH:mm (or :ss) with no timezone info.
Ecto's :utc_datetime cast rejects naive strings without an offset,
so we attach Z (UTC) before handing to the changeset. The user's
picked clock-time is treated as already-UTC — PhoenixKit doesn't
thread per-user timezone preferences yet, and the date/time pickers
in browsers are timezone-agnostic anyway, so what the user types is
what gets stored.
fields is a list of string keys to normalise (typically
["scheduled_start_date"] for the project form). Missing/empty
values pass through untouched so the changeset's required-validation
fires on its own.
@spec notify_deleted(Phoenix.LiveView.Socket.t(), atom(), binary()) :: Phoenix.LiveView.Socket.t()
Emits {:projects, :deleted, %{kind, uuid, close: false, frame_ref}}
on the host topic when in emit mode, no-ops in navigate mode.
Use at list-LV delete-success branches where the LV stays on the
same page after delete (just reloads its own list). The broadcast is
informational — close: false tells PopupHostLive not to pop
the modal that hosts this list. The host learns about the delete
through the canonical UI-intent vocabulary; the list itself stays
open showing the post-delete state.
Contrast with notify_deleted_or_navigate/4 which emits close: true
— used when the LV's own resource was deleted and the modal should
pop.
Builds the params map an apply_action/3 clause expects.
Router mount passes URL params as a map; embed mount passes the atom
:not_mounted_at_router. This helper unifies both: if params is a
map, returns it as-is; otherwise extracts the same string keys from
session ("id", "project_id", "template"). Embedders pass those
keys explicitly when they want :edit or template-prefill behavior.
@spec resolve_live_action(Phoenix.LiveView.Socket.t(), map(), atom()) :: atom()
Resolves the live_action for the embedded mount path.
Router-mounted LVs get live_action set by Phoenix LV before
mount/3 runs (from the live "/...", Mod, :action macro). Embedded
LVs mounted via live_render get nothing — the host has to pass it
via session. Falls back to default (typically :new) when neither
source is present.
Accepts strings ("new", "edit") from session, converts via
String.to_existing_atom/1, then validates against an allowlist
([:new, :edit]) — anything else falls back to default. Without
the allowlist a tampered "live_action": "show" would mint :show
(an existing atom from Phoenix.LiveView land) and then crash inside
apply_action/3 which has no :show clause.