PhoenixKitStaff.Web.Helpers (PhoenixKitStaff v0.3.0)

Copy Markdown View Source

Cross-LV helpers shared by the staff admin LiveViews.

log_operation_error/3 — failure-side audit rows

Staff's documented architectural choice is that PhoenixKitStaff.Activity is success-only at the call site (see CLAUDE.md "Activity logging"). The post-Apr pipeline expects every user-driven mutation to leave an audit row on BOTH :ok AND :error branches so a DB outage / FK violation / constraint failure can't silently erase admin clicks from the activity feed.

The catalogue module's Batch 4 (canonical reference at phoenix_kit_catalogue/lib/phoenix_kit_catalogue/web/helpers.ex) resolves the tension by writing the failure-side row at the LiveView layer, not in the context. Same intent here:

  • One edit point in this helper instead of ~10 LV mutation sites
  • Same action atom the success path would have used (e.g. staff.person_deleted)
  • metadata.db_pending: true so audit-feed readers can distinguish attempted-but-failed from completed actions
  • PII-safe metadata: changeset reasons land error-key field names only (no values), atom reasons land the atom string, other shapes get error_kind: "other"

Helper only fires from handle_event {:error, _} branches — validate cycles never reach it (they're handled by the form's assign_form/2 cycle, no audit row needed for keystrokes).

Summary

Functions

Returns the user's in-flight record by applying the current changeset.

Reads the translations field off the current form's changeset and returns the sub-map for current_lang (or %{}).

Writes a failure-side activity row for a destructive/mutating operation.

Flips :current_lang back to :primary_language when a save fails with errors on translatable primary fields submitted from a secondary tab. Without this, the inline error renders on the primary tab (where the user can't see it) and the form re-renders with no visible change after submit. translatable_fields is the list of DB column names (atoms) from Schema.translatable_fields/0.

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.

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 the pristine assign would lose them because that struct is the 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 form's 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.

log_operation_error(action, socket, opts)

@spec log_operation_error(String.t(), Phoenix.LiveView.Socket.t(), keyword()) ::
  :ok | :activity_unavailable | {:ok, struct()} | {:error, any()}

Writes a failure-side activity row for a destructive/mutating operation.

Required opts

  • :resource_type — string, e.g. "staff_person" / "department"
  • :reason — the {:error, reason} value from the context call

Optional opts

  • :resource_uuid — uuid of the record the operation targeted. Optional because failed CREATE submissions have no uuid yet — in that case the audit row records intent without a target.
  • :target_uuid — second-party uuid for membership operations (the user being added/removed, etc.)
  • :metadata — extra metadata (must be PII-safe). Merged UNDER the helper's own db_pending / error_kind / error_keys / error_atom keys — caller-supplied collisions on those keys are ignored so the audit-feed contract stays stable.

Returns the underlying Activity.log/2 return value (:ok, {:ok, _entry}, {:error, _}, :activity_unavailable); never raises.

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

Flips :current_lang back to :primary_language when a save fails with errors on translatable primary fields submitted from a secondary tab. Without this, the inline error renders on the primary tab (where the user can't see it) and the form re-renders with no visible change after submit. translatable_fields is the list of DB column names (atoms) from Schema.translatable_fields/0.

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 Department, ["job_title", "bio", "skills", "notes"] for Person.