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, assign_embed_user/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.
assign_embed_user/2 bridges the current user across the live_render
process boundary (the on_mount auth hook doesn't run for embedded
LVs); the host passes session["current_user_uuid"]. See its docstring.
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.
Reconstructs the current user + scope for an embedded LiveView mount.
The display label for an assignment's assignee (person email / team /
department name), or nil when unassigned. Requires the assignee assocs
to be preloaded.
The estimated hours for an assignment: its own duration override if set,
otherwise the underlying task's duration (nil-safe). Weekends are honored
per task_counts_weekends?/2.
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.
Whether an assignment counts weekends — its own override, falling back to the project's setting.
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 assign_embed_user(Phoenix.LiveView.Socket.t(), map()) :: Phoenix.LiveView.Socket.t()
Reconstructs the current user + scope for an embedded LiveView mount.
Router-mounted LVs receive :phoenix_kit_current_scope /
:phoenix_kit_current_user from core's :phoenix_kit_ensure_admin
on_mount hook, which runs before mount/3. An LV mounted via
live_render (:not_mounted_at_router) never enters that
live_session, so the hook never runs and both assigns are absent —
leaving user-aware embedded UI blind to who's acting: the comments
drawer's composer flips to "Sign in to post a comment." and
PhoenixKitProjects.Activity.actor_uuid/1 records nil.
The host bridges identity across the live_render process boundary by
passing its own authenticated user's uuid as
session["current_user_uuid"] — a string, never the %User{} struct
(a live_render session is serialized into the client-readable,
signed-but-not-encrypted data-phx-session token, so a struct would
leak the password hash to the browser). This helper reloads that user
and assigns both :phoenix_kit_current_user and
:phoenix_kit_current_scope.
Contract:
- If
:phoenix_kit_current_scopeis already present (router mount — the hook ran beforemount/3) this is a no-op: the canonical scope is never clobbered. - Else if
session["current_user_uuid"]resolves to an active user → assigns that user +Scope.for_user(user). - Else → assigns a
niluser +Scope.for_user(nil)(anonymous), so the assigns always exist and downstreamscope.userreads stay nil-safe.
A provided-but-unresolvable uuid (unknown, inactive, or a transient DB error) degrades to the anonymous branch and logs a warning — comments fall back to "Sign in to post a comment." rather than crashing the embed.
Host responsibilities
The uuid MUST come from the host's trusted server-side assign — its
phoenix_kit_current_scopeassign, i.e.scope.user.uuid— and never from request params: the host owns the page and must not forward attacker-controlled input. The signedlive_rendersession only stops a client from swapping the value after render; it is not server-side authorization — nothing here re-verifies the uuid belongs to a projects-authorized user.This helper reconstructs identity, NOT authorization. Core's
:phoenix_kit_ensure_adminon_mount(thepermission: "projects"gate) runs only for router-mounted admin pages — never for an off-routerlive_rendermount. Embedded mutation handlers are therefore NOT role-gated, so the host MUST gate the embedding page to projects-authorized users. The reconstructed user only drives audit attribution (Activity.actor_uuid/1) and the comments composer.The reconstructed scope is a snapshot taken at mount. Unlike the standalone admin page it carries no live scope-refresh hook, so a permission change / account switch mid-session is not reflected until the embed remounts. Acceptable for an embedded panel/drawer.
@spec assignee_label( PhoenixKitProjects.Schemas.Assignment.t() | PhoenixKitProjects.Schemas.Project.t() ) :: String.t() | nil
The display label for an assignment's assignee (person email / team /
department name), or nil when unassigned. Requires the assignee assocs
to be preloaded.
@spec assignment_hours( PhoenixKitProjects.Schemas.Assignment.t(), PhoenixKitProjects.Schemas.Project.t() ) :: number()
The estimated hours for an assignment: its own duration override if set,
otherwise the underlying task's duration (nil-safe). Weekends are honored
per task_counts_weekends?/2.
@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.
@spec task_counts_weekends?( PhoenixKitProjects.Schemas.Assignment.t(), PhoenixKitProjects.Schemas.Project.t() ) :: boolean()
Whether an assignment counts weekends — its own override, falling back to the project's setting.