PhoenixKitProjects.Web.Helpers (PhoenixKitProjects v0.2.1)

Copy Markdown View Source

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 a phx-change round-trip without a DB hit).
  • merge_translations_attrs(attrs, in_flight_record, primary_fields) — called from the validate/save handlers to:
    1. clean the submitted attrs["translations"] map (strip _unused_* sentinels, drop empty/nil overrides),
    2. merge it on top of the record's existing JSONB so other languages aren't clobbered, and
    3. 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_required failures on save).
  • in_flight_record(socket, form_assign, fallback_assign) — the form's changeset captures the user's typed-but-not-yet-saved primary values from prior validate events. 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

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

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