defmodule PhoenixKitEntities.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(PhoenixKitEntities.Web.Hooks) require Logger import PhoenixKitWeb.Components.MultilangForm alias PhoenixKit.Settings alias PhoenixKit.Utils.Multilang alias PhoenixKit.Utils.Routes alias PhoenixKit.Utils.Slug alias PhoenixKitEntities, as: Entities alias PhoenixKitEntities.EntityData alias PhoenixKitEntities.Events alias PhoenixKitEntities.FormBuilder alias PhoenixKitEntities.Presence alias PhoenixKitEntities.PresenceHelpers # Fields that should keep their primary-language DB column value on secondary tabs. @preserve_fields %{ "title" => :title, "slug" => :slug, "status" => :status, "parent_uuid" => :parent_uuid } @impl true def mount(_params, _session, socket) do # Defer DB queries (entity load, data record load) and presence init to # handle_params/3 — mount runs twice (HTTP + WebSocket), handle_params # runs once. See Phoenix iron law. {:ok, socket} end @impl true def handle_params(%{"entity_slug" => entity_slug, "uuid" => uuid} = params, _uri, socket) do locale = params["locale"] || socket.assigns[:current_locale] # Edit mode with slug entity = Entities.get_entity_by_name(entity_slug, lang: locale) data_record = EntityData.get!(uuid, lang: locale) changeset = EntityData.change(data_record) {:noreply, hydrate_data_form(socket, entity, data_record, changeset, gettext("Edit Data"), locale)} end def handle_params(%{"entity_id" => entity_uuid, "id" => id} = params, _uri, socket) do locale = params["locale"] || socket.assigns[:current_locale] # Edit mode with ID (backwards compat) entity = Entities.get_entity!(entity_uuid, lang: locale) data_record = EntityData.get!(id, lang: locale) changeset = EntityData.change(data_record) {:noreply, hydrate_data_form(socket, entity, data_record, changeset, gettext("Edit Data"), locale)} end def handle_params(%{"entity_slug" => entity_slug} = params, _uri, socket) do locale = params["locale"] || socket.assigns[:current_locale] # Create mode with slug entity = Entities.get_entity_by_name(entity_slug, lang: locale) data_record = %EntityData{entity_uuid: entity.uuid} changeset = EntityData.change(data_record) {:noreply, hydrate_data_form(socket, entity, data_record, changeset, gettext("New Data"), locale)} end def handle_params(%{"entity_id" => entity_uuid} = params, _uri, socket) do locale = params["locale"] || socket.assigns[:current_locale] # Create mode with ID (backwards compat) entity = Entities.get_entity!(entity_uuid, lang: locale) data_record = %EntityData{entity_uuid: entity.uuid} changeset = EntityData.change(data_record) {:noreply, hydrate_data_form(socket, entity, data_record, changeset, gettext("New Data"), locale)} end defp hydrate_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.uuid) do Ecto.Changeset.put_change(changeset, :status, "published") else changeset end form_record_key = case data_record.uuid do nil -> {:new, entity.name} uuid -> uuid end live_source = ensure_live_source(socket) # Multilang state (driven by Languages module globally) multilang_enabled = multilang_enabled?() # 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.uuid do changeset |> rekey_data_on_mount() |> seed_translatable_fields(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_parent_options(entity, data_record, locale) |> mount_multilang() hydrate_data_presence(socket, entity, data_record, form_record_key, current_user) end # Build the parent-picker options for the current record. Excludes the # row itself and any of its descendants (selecting either would create # a cycle). Trashed rows are excluded — they can't meaningfully be a # parent in the active tree. # # Loads the entity's rows once and feeds both the depth-ordered tree # build and the descendant exclusion — `list_tree/2` + # `descendant_uuids/3` would each load the same set independently. defp assign_parent_options(socket, entity, data_record, locale) do rows = EntityData.list_by_entity(entity.uuid, lang: locale) tree = EntityData.tree_from_rows(rows) excluded_uuids = case data_record.uuid do nil -> [] uuid -> [uuid | EntityData.descendant_uuids_from_rows(uuid, rows)] end options = tree |> Enum.reject(&(&1.record.uuid in excluded_uuids)) |> Enum.map(fn %{record: r, depth: d} -> prefix = String.duplicate("— ", d) {prefix <> (r.title || ""), r.uuid} end) assign(socket, :parent_options, options) end defp hydrate_data_presence(socket, entity, data_record, form_record_key, current_user) do if connected?(socket) do Events.subscribe_to_entity_data(entity.uuid) Events.subscribe_to_data_form(entity.uuid, form_record_key) setup_data_editing(socket, data_record, current_user) else assign_no_lock(socket) end end defp setup_data_editing(socket, data_record, current_user) do if data_record.uuid do {:ok, _ref} = PresenceHelpers.track_editing_session(:data, data_record.uuid, socket, current_user) PresenceHelpers.subscribe_to_editing(:data, data_record.uuid) socket = assign_editing_role(socket, data_record.uuid) if socket.assigns.readonly?, do: load_spectator_state(socket, data_record.uuid), else: socket else assign_no_lock(socket) end end defp assign_no_lock(socket) do socket |> assign(:lock_owner?, true) |> assign(:readonly?, false) |> assign(:lock_owner_user, nil) |> assign(:spectators, []) end defp assign_editing_role(socket, data_uuid) do current_user = socket.assigns[:current_user] case PresenceHelpers.get_editing_role(:data, data_uuid, socket.id, current_user.uuid) 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_uuid) {: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_uuid) end end defp load_spectator_state(socket, data_uuid) do # Owner might have unsaved changes - sync from their Presence metadata case PresenceHelpers.get_lock_owner(:data, data_uuid) 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 {:noreply, handle_switch_language(socket, lang_code)} end def handle_event("validate", %{"phoenix_kit_entity_data" => data_params}, socket) do if socket.assigns[:lock_owner?] do do_validate(data_params, socket) else # Spectator - ignore local changes, wait for broadcasts {:noreply, socket} end rescue e -> require Logger Logger.error( "Entity data validate failed: #{Exception.message(e)} " <> "(entity_uuid=#{inspect(record_uuid_for_log(socket, :entity))} " <> "data_record_uuid=#{inspect(record_uuid_for_log(socket, :data_record))})\n" <> Exception.format_stacktrace(__STACKTRACE__) ) {:noreply, put_flash(socket, :error, gettext("Validation error — your data is preserved."))} end def handle_event("save", %{"phoenix_kit_entity_data" => data_params}, socket) do if socket.assigns[:lock_owner?] do do_save(data_params, socket) else {:noreply, put_flash(socket, :error, gettext("Cannot save - you are spectating"))} end rescue e -> require Logger Logger.error( "Entity data save failed: #{Exception.message(e)} " <> "(entity_uuid=#{inspect(record_uuid_for_log(socket, :entity))} " <> "data_record_uuid=#{inspect(record_uuid_for_log(socket, :data_record))})\n" <> Exception.format_stacktrace(__STACKTRACE__) ) {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))} 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.uuid do # Reload from database reloaded_data = EntityData.get_data!(socket.assigns.data_record.uuid) {reloaded_data, EntityData.change(reloaded_data)} else # Reset to empty new data record empty_data = %EntityData{ 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 # Pulls the resource uuid off socket assigns for use in error log # context. Returns `nil` when the assign is missing or the underlying # struct hasn't been hydrated yet (e.g. an exception during the very # first validate before the entity is loaded). defp record_uuid_for_log(socket, key) do case socket.assigns[key] do %{uuid: uuid} -> uuid _ -> nil end end # The parent picker is rendered as a raw <%!-- Parent (optional, same-entity) --%>
<.label for="phoenix_kit_entity_data_parent_uuid">{gettext("Parent")} <%= if (msg = parent_uuid_error(@changeset)) do %>

{msg}

<% end %>
<%!-- Entity Type (Read-only) --%>
<.label>{gettext("Entity Type")}
<%= if @entity.icon do %> <.icon name={@entity.icon} class="w-4 h-4 mr-2" /> <% end %> {@entity.display_name}
<% else %> <%!-- Non-multilang: separate cards (original layout) --%>

<.icon name="hero-information-circle" class="w-6 h-6" /> {gettext("Basic Information")}

<%!-- Title --%>
<.label for="phoenix_kit_entity_data_title">{gettext("Title")} *
<%!-- Slug with Generator --%>
<.label for="phoenix_kit_entity_data_slug"> {gettext("Slug (URL-friendly identifier)")} <.label class="label"> {gettext("Leave empty to auto-generate from title")}
<%!-- Status --%>
<.label for="phoenix_kit_entity_data_status">{gettext("Status")}
<%!-- Parent (optional, same-entity) --%>
<.label for="phoenix_kit_entity_data_parent_uuid">{gettext("Parent")} <%= if (msg = parent_uuid_error(@changeset)) do %>

{msg}

<% end %>
<%!-- Entity Type (Read-only) --%>
<.label>{gettext("Entity Type")}
<%= if @entity.icon do %> <.icon name={@entity.icon} class="w-4 h-4 mr-2" /> <% end %> {@entity.display_name}
<%= if @entity.fields_definition != nil and @entity.fields_definition != [] do %>

<.icon name="hero-list-bullet" class="w-6 h-6" /> {gettext("Custom Fields")}

<%!-- Dynamic form fields generated by FormBuilder --%> {PhoenixKitEntities.FormBuilder.build_fields(@entity, f, wrapper_class: "mb-6", disabled: @readonly?, lang_code: nil )}
<% end %> <% end %> <%!-- Form Actions --%>
<.link navigate={PhoenixKit.Utils.Routes.path("/admin/entities/#{@entity.name}/data")} class="btn btn-outline" > {gettext("Cancel")}
""" end end