PhoenixKitProjects.Web.AITranslateFormHelpers (PhoenixKitProjects v0.7.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. The merge policy now lives directly in the form LVs as plain Map.merge/2 — explicit user-click means AI value always wins, and the form UI is locked while a translation is in flight, so there's no user-edits-during-job race to mitigate.

Summary

Functions

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

Bump the progress-UI state when a translation lifecycle event lands (:translation_completed or :translation_failed). Both terminal outcomes advance the bar — the UX is "this job finished, the lang is no longer spinning", and failure is communicated separately via the flash, not by holding the progress count back.

Bump the progress-UI state when a new dispatch starts.

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

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.

bump_translation_completed(socket, lang)

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

Bump the progress-UI state when a translation lifecycle event lands (:translation_completed or :translation_failed). Both terminal outcomes advance the bar — the UX is "this job finished, the lang is no longer spinning", and failure is communicated separately via the flash, not by holding the progress count back.

Takes the lang so the helper can detect duplicate / stale terminal events (e.g. the same :translation_completed arriving twice on a PubSub reconnect, or a stale event from a previous session's in-flight set still in transit). When lang is no longer in :ai_translate_in_flight, treat as a no-op: don't double-bump progress past total and don't flip status back to :in_progress for a session that already reached :completed.

Also removes lang from the in-flight list — caller passes the socket BEFORE removal, this helper consolidates the removal + progress bump so the two stay in sync.

Flips status to :completed only when the in-flight list goes empty as a result of this bump.

bump_translation_started(socket, started_count)

@spec bump_translation_started(Phoenix.LiveView.Socket.t(), non_neg_integer()) ::
  Phoenix.LiveView.Socket.t()

Bump the progress-UI state when a new dispatch starts.

  • started_count — number of langs the host just enqueued. For single-lang it's 1; for bulk */** the current call sites pass length(in_flight) from enqueue_all_missing/2, which is enqueued + conflicts. See the forward-looking note below for the latent accounting risk that ride-along brings.

When the previous session was nil or :completed, this RESETS the bar to a fresh :in_progress session sized for the new dispatch. When the previous session was still :in_progress, this ADDS to the running total.

The additive :in_progress branch is forward-looking — under the current UI the dispatch button is disabled whenever has_in_flight?(ai_translate) (see ai_translate_bar.ex#action_disabled?/1), so a second dispatch can't start while jobs are running and the branch is unreachable through the modal. Kept so a future host that allows mid-flight dispatch (e.g. a queue-style admin UI) gets correct accounting without redesigning this helper.

Caveat for that future host: enqueue_all_missing/2 reports conflict-deduplicated langs alongside newly-enqueued ones in its in_flight list. A conflict-dedup'd dispatch doesn't get its own worker run — it rides along with the same job that was already in flight, so the broadcast fan-out emits one :translation_completed per actual job, not one per click. If started_count counts both clicks, progress can only reach the per-job number → progress < total forever. Switch the call site to count newly-enqueued langs only (e.g. length(in_flight -- prev_in_flight)) before enabling mid-flight dispatch.

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?

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.