Cross-cutting helpers for the projects module's LiveView layer.
Right now this is the canonical home for the multilang-form merge
helpers used by ProjectFormLive, TaskFormLive, and
AssignmentFormLive. Each form has its own non-translatable fields
and primary-language columns, but the JSONB-shaping plumbing is
identical — extracted here to avoid three near-identical copies.
See the corresponding form LV's mount/3 and handle_event/3 for the
call sites; the shape they expect:
lang_data(form, current_lang)— pulled-in for<.translatable_field lang_data={...}>so the secondary-tab inputs render the existing override (and edits survive aphx-changeround-trip without a DB hit).merge_translations_attrs(attrs, in_flight_record, primary_fields)— called from thevalidate/savehandlers to:- clean the submitted
attrs["translations"]map (strip_unused_*sentinels, drop empty/niloverrides), - merge it on top of the record's existing JSONB so other languages aren't clobbered, and
- preserve primary-language column values that aren't in the
current submission (the secondary-tab DOM doesn't render
them, so they'd otherwise come back nil and trigger
validate_requiredfailures on save).
- clean the submitted
in_flight_record(socket, form_assign, fallback_assign)— the form's changeset captures the user's typed-but-not-yet-saved primary values from priorvalidateevents. Apply it for the merge-baseline.
Summary
Functions
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 %{}).
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.
Push-navigates to default_path unless the embedder supplied a
session["redirect_to"] override (already on socket as
:embed_redirect_to).
Normalises <input type="datetime-local"> form values to ISO 8601
strings Ecto's :utc_datetime cast accepts.
Builds the params map an apply_action/3 clause expects.
Resolves the live_action for the embedded mount path.
Functions
@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_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.
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 and converts via
String.to_existing_atom/1 (safer than to_atom/1; an unknown action
raises rather than minting atoms from arbitrary user input).