PhoenixKitProjects.Web.Helpers (PhoenixKitProjects v0.5.1)

Copy Markdown View Source

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

assign_embed_state(socket, session)

@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 by PopupHostLive (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.

attach_open_embed_hook(socket)

@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.

close_or_navigate(socket, fallback_path)

@spec close_or_navigate(Phoenix.LiveView.Socket.t(), String.t()) ::
  Phoenix.LiveView.Socket.t()

Cancel / Back behaviour.

In emit mode: broadcasts {:projects, :closed, %{frame_ref}} and returns the socket unchanged.

In navigate mode: push_navigate(to: fallback_path), but if the embedder supplied session["redirect_to"] (already on socket as :embed_redirect_to) it takes precedence — same open-redirect guard as navigate_after_save/3. This honors the host-supplied exit point for both cancel/back AND save success in PR #6's contract.

decode_embeddable_lv(str)

@spec decode_embeddable_lv(String.t()) :: {:ok, module()} | :error

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 (what Atom.to_string/1 produces on a module, and what <.smart_link> puts on the wire)
  • "PhoenixKitProjects.Web.OverviewLive" — the human-friendly form used in docs and PopupHostLive's root_view session example. Prepended with Elixir. 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).

decode_session(json)

@spec decode_session(any()) :: {:ok, map()} | :error

Decodes the phx-value-session JSON blob produced by <.smart_link>. Returns {:ok, map} on success, :error on malformed JSON.

embeddable_lv?(mod)

@spec embeddable_lv?(module()) :: boolean()

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.

embeddable_lvs()

@spec embeddable_lvs() :: [module()]

The canonical list of LVs eligible for embed-mode mounting.

encode_emit_session(session_overrides, source, target_lv)

@spec encode_emit_session(term(), module(), module()) :: String.t()

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.

in_flight_record(socket, form_assign, fallback_assign)

@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.

lang_data(form, current_lang)

@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.

maybe_put_locale(session)

@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.

maybe_switch_to_primary_on_error(socket, arg2, translatable_fields)

@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].

merge_translations_attrs(attrs, record, primary_fields)

@spec merge_translations_attrs(map(), struct(), [String.t()]) :: map()

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.

normalize_datetime_local_attrs(attrs, fields)

@spec normalize_datetime_local_attrs(map(), [String.t()]) :: map()

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.

notify_deleted(socket, kind, uuid)

@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 informationalclose: 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.

notify_deleted_or_navigate(socket, kind, uuid, fallback_path)

@spec notify_deleted_or_navigate(
  Phoenix.LiveView.Socket.t(),
  atom(),
  binary(),
  String.t()
) :: Phoenix.LiveView.Socket.t()

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.

Use this when the LV must leave the current view after a delete (e.g. deleting the project you're showing). For list-LV delete handlers that stay on the same page, use notify_deleted/3.

resolve_action_params(params, session)

@spec resolve_action_params(map() | atom(), map()) :: map()

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.

resolve_live_action(socket, session, default \\ :new)

@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.