# `PhoenixKitProjects.Web.Helpers`
[🔗](https://github.com/BeamLabEU/phoenix_kit_projects/blob/v0.14.0/lib/phoenix_kit_projects/web/helpers.ex#L1)

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.

# `assign_embed_state`

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

# `assign_embed_user`

```elixir
@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_scope` is **already present** (router
    mount — the hook ran before `mount/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 `nil` user + `Scope.for_user(nil)` (anonymous), so
    the assigns always exist and downstream `scope.user` reads 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_scope` assign, i.e. `scope.user.uuid` — and
> **never** from request params: the host owns the page and must not
> forward attacker-controlled input. The signed `live_render` session
> 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_admin` `on_mount` (the `permission: "projects"`
> gate) runs only for router-mounted admin pages — never for an
> off-router `live_render` mount. 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.

# `assignee_label`

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

# `assignment_hours`

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

# `attach_open_embed_hook`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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?`

```elixir
@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`

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

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

# `encode_emit_session`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

# `navigate_after_save`

```elixir
@spec navigate_after_save(Phoenix.LiveView.Socket.t(), String.t(), keyword()) ::
  Phoenix.LiveView.Socket.t()
```

Routes a form-save transition per the socket's `:embed_mode`.

In navigate mode (default) — push_navigates to `default_path` unless
the embedder supplied `session["redirect_to"]` (already on socket as
`:embed_redirect_to`), with the same-host open-redirect guard.

In emit mode — broadcasts `{:projects, :saved, %{kind, action, record,
frame_ref}}` on the host topic and returns the socket unchanged. No
navigation fires. `opts` must include `:kind` and `:record`; `:action`
defaults to `:update`.

The broadcast `record` in the payload is **only `%{uuid: record.uuid}`**,
never the full struct (it may ride the client-readable wire and a
preloaded record would leak PII). `kind` conveys the type; a host that
needs the record re-fetches it by uuid.

## Opts

  * `:kind` (atom) — `:project | :task | :template | :assignment`.
    Required in emit mode; ignored in navigate mode.
  * `:record` (struct) — the saved record. Required in emit mode. Only
    its `uuid` is broadcast (see above).
  * `:action` (atom) — `:create | :update`. Defaults to `:update`.
  * `:next` (`{module(), map()} | nil`) — optional follow-up LV the
    host should open after the save. When set, `PopupHostLive` pops
    the current frame and pushes a new frame for `next` — this is
    how form LVs offer a "create then edit" flow in emit mode that
    mirrors their navigate-mode `push_navigate(to: edit_path)`.

## Open-redirect guard (navigate mode)

The `:embed_redirect_to` override is validated as a *relative* path
before use: must start with `/`, must not start with `//`, must not
contain `://`. Protects naive embedders who forward an unvalidated
`params["return_to"]` from a request query string. Invalid overrides
silently fall back to `default_path`.

# `navigate_or_open`

```elixir
@spec navigate_or_open(
  Phoenix.LiveView.Socket.t(),
  keyword()
) :: Phoenix.LiveView.Socket.t()
```

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.

## Opts

  * `:to` (string) — fallback path used in navigate mode
  * `:open` (`{module(), map()}`) — `{TargetLV, session_overrides}` used
    in emit mode

Both opts are required. In emit mode the open-target's module is
validated against `embeddable_lvs/0`; an unlisted module logs a
warning and is dropped (no broadcast, no navigation — caller's bug).

# `normalize_datetime_local_attrs`

```elixir
@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`

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

# `notify_deleted_or_navigate`

```elixir
@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`

```elixir
@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`

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

# `task_counts_weekends?`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
