PhoenixKitProjects.Web.AITranslateFormHelpers (PhoenixKitProjects v0.5.0)

Copy Markdown View Source

Shared form-LV helpers for the AI translate bar wiring on project, template, and task forms.

Extracted because the three form LVs each held an identical copy of these helpers. Beyond dedup, lifting them out makes the merge_blank_fields_only/2 policy directly unit-testable — the user-edits-win contract is load-bearing for the form UX (a translation that lands mid-edit must not silently clobber what the user typed).

Summary

Functions

Assigns the AI-translate bar's initial mount state onto socket.

Does the resource have at least one non-blank translatable field for lang?

Merges the AI's translated new_lang_map into the existing current_lang_map, with user-typed values winning over AI output.

Merges AI output into the form's current lang map according to the job's scope.

Computes the missing list for the language switcher's ai_translate.missing slot.

Functions

assign_ai_translate_mount_state(socket)

@spec assign_ai_translate_mount_state(Phoenix.LiveView.Socket.t()) ::
  Phoenix.LiveView.Socket.t()

Assigns the AI-translate bar's initial mount state onto socket.

The DB/plugin-backed lookups (default endpoint/prompt UUIDs, the endpoint + prompt lists, default-prompt existence) only run on the connected mount. mount/3 fires twice — once for the dead HTTP render and again on the WS upgrade — and these values are only needed once the modal is interactive, so the dead render gets empty defaults and we avoid five duplicate Settings/plugin round-trips per mount.

has_any_translation?(translations, lang, translatable_fields)

@spec has_any_translation?(map(), String.t(), [atom() | String.t()]) :: boolean()

Does the resource have at least one non-blank translatable field for lang?

merge_blank_fields_only(current_lang_map, new_lang_map)

@spec merge_blank_fields_only(map(), map()) :: map()

Merges the AI's translated new_lang_map into the existing current_lang_map, with user-typed values winning over AI output.

A field is updated by the AI only when the current value is blank (nil, "", or whitespace-only). If the user switched to the target language during the Oban job and typed something in e.g. name, the AI's translated name will NOT overwrite it.

This is the policy fix from PR #12's final codex review — an unconditional Map.merge/2 would silently clobber edits the user made between dispatching the translation and the job completing.

merge_translation_fields(current_lang_map, new_lang_map, arg3)

@spec merge_translation_fields(map(), map(), boolean()) :: map()

Merges AI output into the form's current lang map according to the job's scope.

  • overwrite? == true (the "all" scope) — AI output wins via plain Map.merge/2, mirroring the worker's persisted merge so the open form reflects exactly what was written to the DB. Without this, an "overwrite all" translation would update the DB but leave the open form showing the old values, and a subsequent save would silently revert the overwrite.
  • overwrite? == false (missing-only / single-lang) — defers to merge_blank_fields_only/2 so edits the user made while the job ran are preserved.

missing_languages(language_tabs, primary_language, translations, translatable_fields)

@spec missing_languages([map()], String.t(), map() | nil, [atom() | String.t()]) :: [
  String.t()
]

Computes the missing list for the language switcher's ai_translate.missing slot.

A language is "missing" when it's in the host's enabled-language list, isn't the primary language, and doesn't have any non-blank translatable field for that language code yet.

The non-blank rule matters: %{"es" => %{}} and %{"es" => %{"name" => ""}} both still count as missing — the user hasn't actually translated anything yet, just opened the tab. Treating an empty map as "translated" would hide the sparkle the user is looking for.