Packages

phoenix_kit

1.7.49
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules entities web data_form.ex
Raw

lib/modules/entities/web/data_form.ex

defmodule PhoenixKit.Modules.Entities.Web.DataForm do
@moduledoc """
LiveView for creating and editing entity data records.
Provides dynamic form interface based on entity schema definition.
"""
use PhoenixKitWeb, :live_view
on_mount PhoenixKit.Modules.Entities.Web.Hooks
alias Phoenix.LiveView.JS
alias PhoenixKit.Modules.Entities
alias PhoenixKit.Modules.Entities.EntityData
alias PhoenixKit.Modules.Entities.Events
alias PhoenixKit.Modules.Entities.FormBuilder
alias PhoenixKit.Modules.Entities.Multilang
alias PhoenixKit.Modules.Entities.Presence
alias PhoenixKit.Modules.Entities.PresenceHelpers
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
alias PhoenixKit.Utils.Slug
@impl true
def mount(%{"entity_slug" => entity_slug, "id" => id} = params, _session, socket) do
# Set locale for LiveView process
locale =
params["locale"] || socket.assigns[:current_locale]
# Edit mode with slug
entity = Entities.get_entity_by_name(entity_slug)
data_record = EntityData.get!(id)
changeset = EntityData.change(data_record)
mount_data_form(socket, entity, data_record, changeset, gettext("Edit Data"), locale)
end
def mount(%{"entity_id" => entity_id, "id" => id} = params, _session, socket) do
# Set locale for LiveView process
locale =
params["locale"] || socket.assigns[:current_locale]
# Edit mode with ID (backwards compat)
entity = Entities.get_entity!(entity_id)
data_record = EntityData.get!(id)
changeset = EntityData.change(data_record)
mount_data_form(socket, entity, data_record, changeset, gettext("Edit Data"), locale)
end
def mount(%{"entity_slug" => entity_slug} = params, _session, socket) do
# Set locale for LiveView process
locale =
params["locale"] || socket.assigns[:current_locale]
# Create mode with slug
entity = Entities.get_entity_by_name(entity_slug)
data_record = %EntityData{entity_id: entity.id, entity_uuid: entity.uuid}
changeset = EntityData.change(data_record)
mount_data_form(socket, entity, data_record, changeset, gettext("New Data"), locale)
end
def mount(%{"entity_id" => entity_id} = params, _session, socket) do
# Set locale for LiveView process
locale =
params["locale"] || socket.assigns[:current_locale]
# Create mode with ID (backwards compat)
entity = Entities.get_entity!(entity_id)
data_record = %EntityData{entity_id: entity.id, entity_uuid: entity.uuid}
changeset = EntityData.change(data_record)
mount_data_form(socket, entity, data_record, changeset, gettext("New Data"), locale)
end
defp mount_data_form(socket, entity, data_record, changeset, page_title, locale) do
project_title = Settings.get_project_title()
current_user = socket.assigns[:phoenix_kit_current_user]
# For new records, set default status to "published" to avoid validation errors
changeset =
if is_nil(data_record.id) do
Ecto.Changeset.put_change(changeset, :status, "published")
else
changeset
end
form_record_key =
case data_record.id do
nil -> {:new, entity.name}
id -> id
end
live_source = ensure_live_source(socket)
# Multilang state (driven by Languages module globally)
multilang_enabled = Multilang.enabled?()
primary_language = if multilang_enabled, do: Multilang.primary_language(), else: nil
language_tabs = Multilang.build_language_tabs()
# Lazy re-key: if global primary changed since this record was saved,
# restructure data around the new primary language.
# Also seed _title into JSONB data for backwards compat.
changeset =
if multilang_enabled and data_record.id do
changeset
|> rekey_data_on_mount()
|> seed_title_in_data(data_record)
else
changeset
end
socket =
socket
|> assign(:current_locale, locale)
|> assign(:page_title, page_title)
|> assign(:project_title, project_title)
|> assign(:entity, entity)
|> assign(:data_record, data_record)
|> assign(:changeset, changeset)
|> assign(:current_user, current_user)
|> assign(:form_record_key, form_record_key)
|> assign(:form_record_topic_key, normalize_record_key(form_record_key))
|> assign(:live_source, live_source)
|> assign(:has_unsaved_changes, false)
|> assign(:multilang_enabled, multilang_enabled)
|> assign(:primary_language, primary_language)
|> assign(:current_lang, primary_language)
|> assign(:language_tabs, language_tabs)
socket =
if connected?(socket) do
Events.subscribe_to_entity_data(entity.id)
Events.subscribe_to_data_form(entity.id, form_record_key)
socket =
if data_record.id do
# Track this user in Presence
{:ok, _ref} =
PresenceHelpers.track_editing_session(:data, data_record.id, socket, current_user)
# Subscribe to presence changes
PresenceHelpers.subscribe_to_editing(:data, data_record.id)
# Determine our role (owner or spectator)
socket = assign_editing_role(socket, data_record.id)
# Load spectator state if we're not the owner
if socket.assigns.readonly? do
load_spectator_state(socket, data_record.id)
else
socket
end
else
# New record - no lock needed
socket
|> assign(:lock_owner?, true)
|> assign(:readonly?, false)
|> assign(:lock_owner_user, nil)
|> assign(:spectators, [])
end
socket
else
# Not connected - no lock logic
socket
|> assign(:lock_owner?, true)
|> assign(:readonly?, false)
|> assign(:lock_owner_user, nil)
|> assign(:spectators, [])
end
{:ok, socket}
end
defp assign_editing_role(socket, data_id) do
current_user = socket.assigns[:current_user]
case PresenceHelpers.get_editing_role(:data, data_id, socket.id, current_user.id) do
{:owner, _presences} ->
# I'm the owner - I can edit (or same user in different tab)
socket
|> assign(:lock_owner?, true)
|> assign(:readonly?, false)
|> populate_presence_info(:data, data_id)
{:spectator, _owner_meta, _presences} ->
# Different user is the owner - I'm read-only
socket
|> assign(:lock_owner?, false)
|> assign(:readonly?, true)
|> populate_presence_info(:data, data_id)
end
end
defp load_spectator_state(socket, data_id) do
# Owner might have unsaved changes - sync from their Presence metadata
case PresenceHelpers.get_lock_owner(:data, data_id) do
%{form_state: form_state} when not is_nil(form_state) ->
# Apply owner's form state
params = Map.get(form_state, :params) || Map.get(form_state, "params")
if params do
socket
|> apply_remote_data_params(params)
|> assign(:has_unsaved_changes, true)
else
socket
end
_ ->
# No form state to sync
socket
end
end
@impl true
def terminate(_reason, _socket) do
:ok
end
@impl true
def handle_event("switch_language", %{"lang" => lang_code}, socket) do
enabled = Multilang.enabled_languages()
if lang_code in enabled do
{:noreply, assign(socket, :current_lang, lang_code)}
else
{:noreply, socket}
end
end
def handle_event("validate", %{"phoenix_kit_entity_data" => data_params}, socket) do
if socket.assigns[:lock_owner?] do
entity_id = socket.assigns.entity.id
record_id = socket.assigns.data_record.id
form_data = Map.get(data_params, "data", %{})
data_params =
if socket.assigns.data_record.id do
data_params
else
data_params
|> Map.put("created_by", socket.assigns.current_user.id)
|> Map.put("created_by_uuid", socket.assigns.current_user.uuid)
end
data_params =
if is_nil(record_id) do
# Always use the primary language title for slug generation
current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset)
previous_title = current_data.title || ""
title =
if data_params["title"] do
data_params["title"]
else
# On secondary language tab, title param is absent — keep the existing title
previous_title
end
current_slug = data_params["slug"] || ""
auto_generated_slug = auto_generate_entity_slug(entity_id, record_id, previous_title)
if current_slug == "" || current_slug == auto_generated_slug do
Map.put(data_params, "slug", auto_generate_entity_slug(entity_id, record_id, title))
else
data_params
end
else
data_params
end
current_lang = socket.assigns[:current_lang]
# Inject _title and _slug into form data so they flow through merge_multilang_data
form_data =
form_data
|> inject_title_into_form_data(data_params, current_lang, socket.assigns)
|> inject_slug_into_form_data(data_params, current_lang, socket.assigns)
# On secondary language tabs, preserve primary-language fields that aren't in the form
data_params = preserve_primary_fields(data_params, socket.assigns.changeset)
case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do
{:ok, validated_data} ->
validated_data =
validated_data
|> inject_title_into_form_data(data_params, current_lang, socket.assigns)
|> inject_slug_into_form_data(data_params, current_lang, socket.assigns)
# Strip lang_title/lang_slug after injection — not schema fields
data_params =
data_params
|> Map.delete("lang_title")
|> Map.delete("lang_slug")
final_data = merge_multilang_data(socket.assigns, current_lang, validated_data)
params = Map.put(data_params, "data", final_data)
changeset =
socket.assigns.data_record
|> EntityData.change(params)
|> Map.put(:action, :validate)
socket =
socket
|> assign(:changeset, changeset)
|> broadcast_data_form_state(params)
{:noreply, socket}
{:error, errors} ->
# Preserve full multilang data in both changeset and broadcast
error_data = merge_multilang_data(socket.assigns, current_lang, form_data)
data_params =
data_params
|> Map.delete("lang_title")
|> Map.delete("lang_slug")
error_params = Map.put(data_params, "data", error_data)
changeset =
socket.assigns.data_record
|> EntityData.change(error_params)
|> add_form_errors(errors)
|> Map.put(:action, :validate)
socket =
socket
|> assign(:changeset, changeset)
|> broadcast_data_form_state(error_params)
{:noreply, socket}
end
else
# Spectator - ignore local changes, wait for broadcasts
{:noreply, socket}
end
end
def handle_event("save", %{"phoenix_kit_entity_data" => data_params}, socket) do
if socket.assigns[:lock_owner?] do
# Extract the data field from params
form_data = Map.get(data_params, "data", %{})
current_lang = socket.assigns[:current_lang]
# Inject _title and _slug into form data so they flow through merge_multilang_data
form_data =
form_data
|> inject_title_into_form_data(data_params, current_lang, socket.assigns)
|> inject_slug_into_form_data(data_params, current_lang, socket.assigns)
# On secondary language tabs, preserve primary-language fields that aren't in the form
data_params = preserve_primary_fields(data_params, socket.assigns.changeset)
# Validate the form data against entity field definitions
case FormBuilder.validate_data(socket.assigns.entity, form_data, current_lang) do
{:ok, validated_data} ->
validated_data =
validated_data
|> inject_title_into_form_data(data_params, current_lang, socket.assigns)
|> inject_slug_into_form_data(data_params, current_lang, socket.assigns)
# Strip lang_title/lang_slug after injection — not schema fields
data_params =
data_params
|> Map.delete("lang_title")
|> Map.delete("lang_slug")
final_data = merge_multilang_data(socket.assigns, current_lang, validated_data)
# Add metadata to params
params =
data_params
|> Map.put("data", final_data)
|> maybe_add_creator_id(socket.assigns.current_user, socket.assigns.data_record)
try do
case save_data_record(socket, params) do
{:ok, saved_record} ->
if socket.assigns.data_record.id do
# Update — stay on page, refresh changeset from saved record
changeset = EntityData.change(saved_record)
socket =
socket
|> assign(:data_record, saved_record)
|> assign(:changeset, changeset)
|> put_flash(:info, gettext("Data record saved successfully"))
|> broadcast_data_form_state(params)
{:noreply, socket}
else
# Create — navigate to the edit page for the new record
entity_name = socket.assigns.entity.name
socket =
socket
|> put_flash(:info, gettext("Data record created successfully"))
|> push_navigate(
to:
Routes.path(
"/admin/entities/#{entity_name}/data/#{saved_record.uuid}/edit",
locale: socket.assigns.current_locale_base
)
)
{:noreply, socket}
end
{:error, %Ecto.Changeset{} = changeset} ->
socket =
socket
|> assign(:changeset, changeset)
|> broadcast_data_form_state(params)
{:noreply, socket}
end
rescue
e ->
require Logger
Logger.error("Entity data save failed: #{Exception.message(e)}")
{:noreply,
put_flash(socket, :error, gettext("Something went wrong. Please try again."))}
end
{:error, errors} ->
# Preserve full multilang data in both changeset and broadcast
error_data = merge_multilang_data(socket.assigns, current_lang, form_data)
data_params =
data_params
|> Map.delete("lang_title")
|> Map.delete("lang_slug")
error_params = Map.put(data_params, "data", error_data)
changeset =
socket.assigns.data_record
|> EntityData.change(error_params)
|> add_form_errors(errors)
error_list =
Enum.map_join(errors, "; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end)
socket =
socket
|> assign(:changeset, changeset)
|> put_flash(
:error,
gettext("Field validation errors: %{errors}", errors: error_list)
)
|> broadcast_data_form_state(error_params)
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, gettext("Cannot save - you are spectating"))}
end
end
def handle_event("reset", _params, socket) do
if socket.assigns[:lock_owner?] do
# Reload data record from database or reset to empty state
{data_record, changeset} =
if socket.assigns.data_record.id do
# Reload from database
reloaded_data = EntityData.get_data!(socket.assigns.data_record.id)
{reloaded_data, EntityData.change(reloaded_data)}
else
# Reset to empty new data record
empty_data = %EntityData{
entity_id: socket.assigns.entity.id,
entity_uuid: socket.assigns.entity.uuid
}
changeset =
empty_data
|> EntityData.change()
|> Ecto.Changeset.put_change(:status, "published")
{empty_data, changeset}
end
socket =
socket
|> assign(:data_record, data_record)
|> assign(:changeset, changeset)
|> put_flash(:info, gettext("Changes reset to last saved state"))
|> broadcast_data_form_state(extract_changeset_params(changeset))
{:noreply, socket}
else
{:noreply, put_flash(socket, :error, gettext("Cannot reset - you are spectating"))}
end
end
def handle_event("generate_slug", _params, socket) do
if socket.assigns[:lock_owner?] do
do_generate_slug(socket)
else
{:noreply, socket}
end
end
## Live updates
@impl true
def handle_info({:data_form_change, entity_id, record_key, payload, source}, socket) do
cond do
source == socket.assigns.live_source ->
{:noreply, socket}
entity_id != socket.assigns.entity.id ->
{:noreply, socket}
normalize_record_key(record_key) != socket.assigns.form_record_topic_key ->
{:noreply, socket}
true ->
params = Map.get(payload, :params) || Map.get(payload, "params") || %{}
socket =
socket
|> apply_remote_data_params(params)
{:noreply, socket}
end
end
def handle_info({:data_updated, entity_id, data_id}, socket) do
cond do
entity_id != socket.assigns.entity.id ->
{:noreply, socket}
socket.assigns.data_record.id != data_id ->
{:noreply, socket}
# Ignore our own saves — the save handler already refreshes state
socket.assigns[:lock_owner?] ->
{:noreply, socket}
true ->
data_record = EntityData.get_data!(data_id)
changeset = EntityData.change(data_record)
socket =
socket
|> assign(:data_record, data_record)
|> assign(:form_record_key, data_record.id)
|> assign(:form_record_topic_key, normalize_record_key(data_record.id))
|> assign(:changeset, changeset)
|> put_flash(
:info,
gettext("Record updated in another session. Showing latest changes.")
)
{:noreply, socket}
end
end
def handle_info({:data_deleted, entity_id, data_id}, socket) do
cond do
entity_id != socket.assigns.entity.id ->
{:noreply, socket}
socket.assigns.data_record.id != data_id ->
{:noreply, socket}
true ->
socket =
socket
|> put_flash(:error, gettext("This record was removed in another session."))
|> push_navigate(
to:
Routes.path("/admin/entities/#{socket.assigns.entity.name}/data",
locale: socket.assigns.current_locale_base
)
)
{:noreply, socket}
end
end
def handle_info({:entity_created, _}, socket), do: {:noreply, socket}
def handle_info({:entity_updated, entity_id}, socket) do
if entity_id == socket.assigns.entity.id do
entity = Entities.get_entity!(entity_id)
# If entity was archived or unpublished, redirect to entities list
if entity.status != "published" do
{:noreply,
socket
|> put_flash(
:warning,
gettext("Entity '%{name}' was %{status} in another session.",
name: entity.display_name,
status: entity.status
)
)
|> redirect(
to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base)
)}
else
socket =
socket
|> refresh_entity_assignment(entity)
|> put_flash(:info, gettext("Entity schema updated. Form revalidated."))
{:noreply, socket}
end
else
{:noreply, socket}
end
end
def handle_info({:entity_deleted, entity_id}, socket) do
if entity_id == socket.assigns.entity.id do
socket =
socket
|> put_flash(:error, gettext("Entity was deleted in another session."))
|> push_navigate(
to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base)
)
{:noreply, socket}
else
{:noreply, socket}
end
end
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
# Someone joined or left - check if our role changed
if socket.assigns.data_record && socket.assigns.data_record.id do
data_id = socket.assigns.data_record.id
was_owner = socket.assigns[:lock_owner?]
# Re-evaluate our role
socket = assign_editing_role(socket, data_id)
# If we were promoted from spectator to owner, reload fresh data
if !was_owner && socket.assigns[:lock_owner?] do
data_record = EntityData.get_data!(data_id)
socket
|> assign(:data_record, data_record)
|> assign(:changeset, EntityData.change(data_record))
|> assign(:has_unsaved_changes, false)
|> then(&{:noreply, &1})
else
# Just a presence update (someone joined/left as spectator)
{:noreply, socket}
end
else
{:noreply, socket}
end
end
# ── Lazy re-keying helpers (primary language change) ────────
# Re-keys JSONB data in changeset if embedded primary != global primary.
defp rekey_data_on_mount(changeset) do
current_data = Ecto.Changeset.get_field(changeset, :data)
rekeyed = Multilang.maybe_rekey_data(current_data)
if rekeyed != current_data do
Ecto.Changeset.put_change(changeset, :data, rekeyed)
else
changeset
end
end
# Seeds `_title` and `_slug` into the JSONB data column for existing records on mount.
# Handles backwards compat: migrates from metadata["translations"] to data[lang]["_title"].
defp seed_title_in_data(changeset, data_record) do
data = Ecto.Changeset.get_field(changeset, :data) || %{}
if Multilang.multilang_data?(data) do
primary = data["_primary_language"]
primary_data = Map.get(data, primary, %{})
changeset =
if Map.has_key?(primary_data, "_title") do
changeset
else
title = Ecto.Changeset.get_field(changeset, :title)
do_seed_title(changeset, data, data_record, primary, primary_data, title)
end
# Also seed _slug if not already present
seed_slug_in_data(changeset)
else
changeset
end
end
defp seed_slug_in_data(changeset) do
data = Ecto.Changeset.get_field(changeset, :data) || %{}
primary = data["_primary_language"]
primary_data = Map.get(data, primary, %{})
if Map.has_key?(primary_data, "_slug") do
changeset
else
slug = Ecto.Changeset.get_field(changeset, :slug)
if is_binary(slug) and slug != "" do
updated_primary = Map.put(primary_data, "_slug", slug)
data = Map.put(data, primary, updated_primary)
Ecto.Changeset.put_change(changeset, :data, data)
else
changeset
end
end
end
defp do_seed_title(changeset, data, data_record, primary, primary_data, title) do
# Seed primary _title from the title column
updated_primary = Map.put(primary_data, "_title", title || "")
data = Map.put(data, primary, updated_primary)
# Migrate secondary titles from metadata["translations"]
metadata = Ecto.Changeset.get_field(changeset, :metadata) || %{}
{data, metadata} = migrate_title_translations(data, metadata, title)
changeset = Ecto.Changeset.put_change(changeset, :data, data)
# Update title column if primary was rekeyed
changeset = maybe_sync_rekeyed_title(changeset, data, data_record, primary, title)
if metadata != (Ecto.Changeset.get_field(changeset, :metadata) || %{}) do
Ecto.Changeset.put_change(changeset, :metadata, metadata)
else
changeset
end
end
defp migrate_title_translations(data, metadata, primary_title) do
translations = metadata["translations"] || %{}
Enum.reduce(translations, {data, metadata}, fn
{lang_code, %{"title" => lang_title}}, {d, m}
when is_binary(lang_title) and lang_title != "" ->
d = put_secondary_title(d, lang_code, lang_title, primary_title)
m = clean_title_translation(m, lang_code)
{d, m}
_, acc ->
acc
end)
end
defp put_secondary_title(data, _lang_code, lang_title, primary_title)
when lang_title == primary_title,
do: data
defp put_secondary_title(data, lang_code, lang_title, _primary_title) do
lang_data = Map.get(data, lang_code, %{})
Map.put(data, lang_code, Map.put(lang_data, "_title", lang_title))
end
defp clean_title_translation(metadata, lang_code) do
cleaned = metadata |> Map.get("translations", %{}) |> Map.delete(lang_code)
if map_size(cleaned) == 0,
do: Map.delete(metadata, "translations"),
else: Map.put(metadata, "translations", cleaned)
end
defp maybe_sync_rekeyed_title(changeset, data, data_record, primary, title) do
old_embedded = get_in(data_record.data || %{}, ["_primary_language"])
if old_embedded && old_embedded != primary do
new_title = get_in(data, [primary, "_title"])
if is_binary(new_title) and new_title != "" and new_title != title do
Ecto.Changeset.put_change(changeset, :title, new_title)
else
changeset
end
else
changeset
end
end
# Helper Functions
defp merge_multilang_data(assigns, lang_code, validated_data) do
existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{}
cond do
# Multilang enabled — use language-aware merge
assigns[:multilang_enabled] == true ->
Multilang.put_language_data(existing_data, lang_code, validated_data)
# Multilang disabled but data has multilang structure — preserve it
# (prevents translation loss if Languages module is temporarily disabled)
Multilang.multilang_data?(existing_data) ->
Multilang.put_language_data(existing_data, lang_code, validated_data)
# Flat data, no multilang — pass through as-is
true ->
validated_data
end
end
# When on a secondary language tab, preserve primary-language DB fields.
# Title on secondary tab goes to JSONB _title via inject_title_into_form_data;
# the DB title column must keep the primary language value.
@preserve_fields %{"title" => :title, "slug" => :slug, "status" => :status}
defp preserve_primary_fields(data_params, changeset) do
Enum.reduce(@preserve_fields, data_params, fn {str_key, atom_key}, acc ->
if Map.has_key?(acc, str_key) do
acc
else
value = Ecto.Changeset.get_field(changeset, atom_key)
if value, do: Map.put(acc, str_key, value), else: acc
end
end)
end
# Injects _title into form data map so it flows through merge_multilang_data/3.
# On primary tab: _title comes from data_params["title"] (the DB column field).
# On secondary tab: _title comes from data_params["lang_title"] (separate input).
defp inject_title_into_form_data(form_data, data_params, current_lang, assigns) do
if assigns[:multilang_enabled] == true do
primary = assigns[:primary_language]
title =
if current_lang == primary do
data_params["title"]
else
data_params["lang_title"]
end
if is_binary(title) do
Map.put(form_data, "_title", title)
else
# No title submitted — preserve existing _title from JSONB data
existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{}
case Multilang.get_raw_language_data(existing_data, current_lang) do
%{"_title" => existing_title} -> Map.put(form_data, "_title", existing_title)
_ -> form_data
end
end
else
form_data
end
end
defp do_generate_slug(socket) do
changeset = socket.assigns.changeset
current_lang = socket.assigns[:current_lang]
primary = socket.assigns[:primary_language]
is_secondary = socket.assigns[:multilang_enabled] && current_lang != primary
title = slug_source_title(changeset, is_secondary, current_lang)
if title == "" do
{:noreply, socket}
else
{params, changeset} = build_slug_params(socket, title, is_secondary, current_lang)
socket =
socket
|> assign(:changeset, changeset)
|> broadcast_data_form_state(params)
{:noreply, socket}
end
end
defp slug_source_title(changeset, true = _secondary, current_lang) do
data = Ecto.Changeset.get_field(changeset, :data) || %{}
case Multilang.get_language_data(data, current_lang) do
%{"_title" => lang_title} when is_binary(lang_title) and lang_title != "" -> lang_title
_ -> Ecto.Changeset.get_field(changeset, :title) || ""
end
end
defp slug_source_title(changeset, _primary, _current_lang) do
Ecto.Changeset.get_field(changeset, :title) || ""
end
defp build_slug_params(socket, title, is_secondary, current_lang) do
changeset = socket.assigns.changeset
db_entity_id = Ecto.Changeset.get_field(changeset, :entity_id)
db_title = Ecto.Changeset.get_field(changeset, :title) || ""
status = Ecto.Changeset.get_field(changeset, :status) || "draft"
data = Ecto.Changeset.get_field(changeset, :data) || %{}
created_by = Ecto.Changeset.get_field(changeset, :created_by)
{slug, data} =
compute_slug_and_data(socket, title, is_secondary, current_lang, changeset, data)
params = %{
"entity_id" => db_entity_id,
"title" => db_title,
"slug" => slug,
"status" => status,
"data" => data,
"created_by" => created_by
}
changeset =
socket.assigns.data_record
|> EntityData.change(params)
|> Map.put(:action, :validate)
{params, changeset}
end
defp compute_slug_and_data(_socket, title, true = _secondary, current_lang, changeset, data) do
slug_text = Slug.slugify(title)
lang_data = Multilang.get_raw_language_data(data, current_lang)
updated_lang = Map.put(lang_data, "_slug", slug_text)
updated_data = Multilang.put_language_data(data, current_lang, updated_lang)
{Ecto.Changeset.get_field(changeset, :slug), updated_data}
end
defp compute_slug_and_data(socket, title, _primary, _current_lang, _changeset, data) do
entity_id = socket.assigns.entity.id
record_id = socket.assigns.data_record.id
slug_text = auto_generate_entity_slug(entity_id, record_id, title)
{slug_text, data}
end
# Injects _slug into form data map so it flows through merge_multilang_data/3.
# On primary tab: _slug comes from data_params["slug"] (the DB column field).
# On secondary tab: _slug comes from data_params["lang_slug"] (separate input).
defp inject_slug_into_form_data(form_data, data_params, current_lang, assigns) do
if assigns[:multilang_enabled] == true do
primary = assigns[:primary_language]
slug =
if current_lang == primary do
data_params["slug"]
else
data_params["lang_slug"]
end
if is_binary(slug) do
Map.put(form_data, "_slug", slug)
else
# No slug submitted — preserve existing _slug from JSONB data
existing_data = Ecto.Changeset.get_field(assigns.changeset, :data) || %{}
case Multilang.get_raw_language_data(existing_data, current_lang) do
%{"_slug" => existing_slug} -> Map.put(form_data, "_slug", existing_slug)
_ -> form_data
end
end
else
form_data
end
end
defp broadcast_data_form_state(socket, params) when is_map(params) do
socket =
if connected?(socket) &&
socket.assigns[:form_record_key] &&
socket.assigns[:entity] &&
socket.assigns.data_record.id &&
socket.assigns[:lock_owner?] do
data_id = socket.assigns.data_record.id
topic = PresenceHelpers.editing_topic(:data, data_id)
payload = %{params: params}
# Update Presence metadata with form state (for spectators to sync)
Presence.update(self(), topic, socket.id, fn meta ->
Map.put(meta, :form_state, payload)
end)
# Also broadcast for real-time sync to spectators
Events.broadcast_data_form_change(
socket.assigns.entity.id,
socket.assigns.form_record_key,
payload,
source: socket.assigns.live_source
)
socket
else
socket
end
# Mark that we have unsaved changes
assign(socket, :has_unsaved_changes, true)
end
defp apply_remote_data_params(socket, params) when is_map(params) do
# Build the changeset WITHOUT enforcing validations yet
# This ensures we capture the exact remote state, even invalid values
changeset =
socket.assigns.data_record
|> Ecto.Changeset.cast(params, [
:entity_id,
:title,
:slug,
:status,
:data,
:metadata,
:created_by
])
|> Map.put(:action, :validate)
# Apply changes to get the updated record with remote values
updated_record = Ecto.Changeset.apply_changes(changeset)
# Now create a validated changeset for display
# This will show validation errors but preserve the remote values
validated_changeset = EntityData.change(updated_record)
socket
|> assign(:data_record, updated_record)
|> assign(:changeset, validated_changeset)
|> assign(:has_unsaved_changes, true)
end
defp refresh_entity_assignment(socket, entity) do
params = extract_changeset_params(socket.assigns.changeset)
data_record = %{
socket.assigns.data_record
| entity: entity,
entity_id: entity.id,
entity_uuid: entity.uuid
}
changeset =
data_record
|> EntityData.change(params)
|> Map.put(:action, :validate)
# Refresh multilang assigns (driven by Languages module globally)
multilang_enabled = Multilang.enabled?()
primary_language = if multilang_enabled, do: Multilang.primary_language(), else: nil
language_tabs = Multilang.build_language_tabs()
current_lang = socket.assigns[:current_lang]
enabled_langs = Multilang.enabled_languages()
# Reset current_lang when multilang is disabled or language was removed
current_lang =
cond do
not multilang_enabled -> nil
current_lang not in enabled_langs -> primary_language
true -> current_lang
end
socket
|> assign(:entity, entity)
|> assign(:data_record, data_record)
|> assign(:changeset, changeset)
|> assign(:multilang_enabled, multilang_enabled)
|> assign(:primary_language, primary_language)
|> assign(:current_lang, current_lang)
|> assign(:language_tabs, language_tabs)
end
defp extract_changeset_params(changeset) do
changeset
|> Ecto.Changeset.apply_changes()
|> Map.from_struct()
|> Map.take([:entity_id, :title, :slug, :status, :data, :metadata, :created_by])
|> Enum.into(%{}, fn {key, value} -> {to_string(key), value} end)
end
defp save_data_record(socket, data_params) do
if socket.assigns.data_record.id do
EntityData.update(socket.assigns.data_record, data_params)
else
EntityData.create(data_params)
end
end
defp maybe_add_creator_id(params, current_user, data_record) do
if data_record.id do
# Editing existing record - don't change creator
params
else
# Creating new record - set creator
params
|> Map.put("created_by", current_user.id)
|> Map.put("created_by_uuid", current_user.uuid)
end
end
defp add_form_errors(changeset, errors) do
Enum.reduce(errors, changeset, fn {field_key, field_errors}, acc ->
Enum.reduce(field_errors, acc, fn error, inner_acc ->
Ecto.Changeset.add_error(inner_acc, :data, "#{field_key}: #{error}")
end)
end)
end
defp ensure_live_source(socket) do
socket.assigns[:live_source] ||
(socket.id ||
"entities-data-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false))
end
defp normalize_record_key({:new, key}) when is_atom(key), do: "new-#{Atom.to_string(key)}"
defp normalize_record_key({:new, key}) when is_binary(key), do: "new-#{key}"
defp normalize_record_key({:new, key}), do: "new-#{to_string(key)}"
defp normalize_record_key(key) when is_integer(key), do: Integer.to_string(key)
defp normalize_record_key(key) when is_atom(key), do: Atom.to_string(key)
defp normalize_record_key(key) when is_binary(key), do: key
defp normalize_record_key(key), do: to_string(key)
defp switch_lang_js(lang_code, current_lang) do
if lang_code == current_lang do
# Already on this tab — no-op to prevent skeleton ghosts
%JS{}
else
JS.push("switch_language", value: %{lang: lang_code})
|> JS.add_class("hidden", to: "[data-translatable=fields]")
|> JS.remove_class("hidden", to: "[data-translatable=skeletons]")
end
end
defp auto_generate_entity_slug(_entity_id, _record_id, title) when title in [nil, ""], do: ""
defp auto_generate_entity_slug(entity_id, current_record_id, title) do
title
|> Slug.slugify()
|> Slug.ensure_unique(&slug_taken_by_other?(entity_id, &1, current_record_id))
end
defp slug_taken_by_other?(_entity_id, "", _current_record_id), do: false
defp slug_taken_by_other?(entity_id, candidate, current_record_id) do
case EntityData.get_by_slug(entity_id, candidate) do
nil ->
false
%EntityData{id: id} ->
is_nil(current_record_id) || id != current_record_id
end
end
defp populate_presence_info(socket, type, id) do
# Get all presences sorted by joined_at (FIFO order)
presences = PresenceHelpers.get_sorted_presences(type, id)
# Extract owner (first in list) and spectators (rest of list)
{lock_owner_user, lock_info, spectators} =
case presences do
[] ->
{nil, nil, []}
[{owner_socket_id, owner_meta} | spectator_list] ->
# Build owner info
lock_info = %{
socket_id: owner_socket_id,
user_id: owner_meta.user_id
}
# Map spectators to expected format
spectators =
Enum.map(spectator_list, fn {spectator_socket_id, meta} ->
%{
socket_id: spectator_socket_id,
user: meta.user,
user_id: meta.user_id
}
end)
{owner_meta.user, lock_info, spectators}
end
socket
|> assign(:lock_owner_user, lock_owner_user)
|> assign(:lock_info, lock_info)
|> assign(:spectators, spectators)
end
end