Packages
phoenix_kit
1.7.78
1.7.208
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
Current section
Files
lib/modules/entities/web/data_navigator.ex
defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do
@moduledoc """
LiveView for browsing and managing entity data records.
Provides table view with pagination, search, filtering, and bulk operations.
"""
use PhoenixKitWeb, :live_view
on_mount PhoenixKit.Modules.Entities.Web.Hooks
alias PhoenixKit.Modules.Entities
alias PhoenixKit.Modules.Entities.EntityData
alias PhoenixKit.Modules.Entities.Events
alias PhoenixKit.Modules.Entities.Multilang
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.Routes
def mount(_params, _session, socket) do
project_title = Settings.get_project_title()
entities = Entities.list_entities()
# Subscribe to entity definition events so we know about creates/updates/deletes
if connected?(socket) do
Events.subscribe_to_entities()
Events.subscribe_to_all_data()
end
# Set defaults only — entity resolution and data loading deferred to handle_params
socket =
socket
|> assign(:page_title, gettext("Data Navigator"))
|> assign(:project_title, project_title)
|> assign(:entities, entities)
|> assign(:total_records, 0)
|> assign(:published_records, 0)
|> assign(:draft_records, 0)
|> assign(:archived_records, 0)
|> assign(:selected_entity, nil)
|> assign(:selected_entity_uuid, nil)
|> assign(:selected_status, "all")
|> assign(:selected_uuids, MapSet.new())
|> assign(:search_term, "")
|> assign(:view_mode, "table")
|> assign(:entity_data_records, [])
{:ok, socket}
end
def handle_params(params, _url, socket) do
# Resolve entity from slug in params
{entity, entity_uuid} = resolve_entity_from_params(params, socket)
# Update stats if entity changed
socket = maybe_update_entity_stats(socket, entity_uuid)
# Set page title based on entity
page_title =
if entity, do: entity.display_name, else: gettext("Data Navigator")
# Extract filter params with defaults
status = params["status"] || "all"
search_term = params["search"] || ""
view_mode = params["view"] || "table"
socket =
socket
|> assign(:page_title, page_title)
|> assign(:selected_entity, entity)
|> assign(:selected_entity_uuid, entity_uuid)
|> assign(:selected_status, status)
|> assign(:search_term, search_term)
|> assign(:view_mode, view_mode)
|> apply_filters()
{:noreply, socket}
end
# Resolve entity and entity_uuid from URL params
defp resolve_entity_from_params(params, socket) do
case params["entity_slug"] || params["entity_id"] do
nil ->
{socket.assigns.selected_entity, socket.assigns.selected_entity_uuid}
"" ->
{nil, nil}
slug when is_binary(slug) ->
resolve_entity_by_slug(slug)
end
end
# Look up entity by slug/name
defp resolve_entity_by_slug(slug) do
case Entities.get_entity_by_name(slug) do
nil -> {nil, nil}
entity -> {entity, entity.uuid}
end
end
# Update entity stats if entity changed
defp maybe_update_entity_stats(socket, new_entity_uuid) do
if new_entity_uuid != socket.assigns.selected_entity_uuid do
update_entity_stats(socket, new_entity_uuid)
else
socket
end
end
# Update socket with fresh entity statistics
defp update_entity_stats(socket, entity_uuid) do
stats = EntityData.get_data_stats(entity_uuid)
socket
|> assign(:total_records, stats.total_records)
|> assign(:published_records, stats.published_records)
|> assign(:draft_records, stats.draft_records)
|> assign(:archived_records, stats.archived_records)
end
def handle_event("toggle_view_mode", %{"mode" => mode}, socket) do
params =
build_url_params(
socket.assigns.selected_entity_uuid,
socket.assigns.selected_status,
socket.assigns.search_term,
mode
)
path = build_base_path(socket.assigns.selected_entity_uuid)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:view_mode, mode)
|> assign(:selected_uuids, MapSet.new())
|> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base))
{:noreply, socket}
end
def handle_event("filter_by_entity", %{"entity_uuid" => ""}, socket) do
# No entity selected - redirect to entities list since global data view no longer exists
socket =
socket
|> put_flash(:info, gettext("Please select an entity to view its data"))
|> redirect(to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base))
{:noreply, socket}
end
def handle_event("filter_by_entity", %{"entity_uuid" => entity_uuid}, socket) do
params =
build_url_params(
entity_uuid,
socket.assigns.selected_status,
socket.assigns.search_term,
socket.assigns.view_mode
)
path = build_base_path(entity_uuid)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_uuids, MapSet.new())
|> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base))
{:noreply, socket}
end
def handle_event("filter_by_status", %{"status" => status}, socket) do
params =
build_url_params(
socket.assigns.selected_entity_uuid,
status,
socket.assigns.search_term,
socket.assigns.view_mode
)
path = build_base_path(socket.assigns.selected_entity_uuid)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_uuids, MapSet.new())
|> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base))
{:noreply, socket}
end
def handle_event("search", %{"search" => %{"term" => term}}, socket) do
params =
build_url_params(
socket.assigns.selected_entity_uuid,
socket.assigns.selected_status,
term,
socket.assigns.view_mode
)
path = build_base_path(socket.assigns.selected_entity_uuid)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_uuids, MapSet.new())
|> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base))
{:noreply, socket}
end
def handle_event("clear_filters", _params, socket) do
params =
build_url_params(
socket.assigns.selected_entity_uuid,
"all",
"",
socket.assigns.view_mode
)
path = build_base_path(socket.assigns.selected_entity_uuid)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_uuids, MapSet.new())
|> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base))
{:noreply, socket}
end
def handle_event("archive_data", %{"uuid" => uuid}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
data_record = EntityData.get!(uuid)
case EntityData.update_data(data_record, %{status: "archived"}) do
{:ok, _data} ->
socket =
socket
|> apply_filters()
|> put_flash(:info, gettext("Data record archived successfully"))
{:noreply, socket}
{:error, _changeset} ->
socket = put_flash(socket, :error, gettext("Failed to archive data record"))
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, gettext("Not authorized"))}
end
end
def handle_event("restore_data", %{"uuid" => uuid}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
data_record = EntityData.get!(uuid)
case EntityData.update_data(data_record, %{status: "published"}) do
{:ok, _data} ->
socket =
socket
|> apply_filters()
|> put_flash(:info, gettext("Data record restored successfully"))
{:noreply, socket}
{:error, _changeset} ->
socket = put_flash(socket, :error, gettext("Failed to restore data record"))
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, gettext("Not authorized"))}
end
end
def handle_event("toggle_status", %{"uuid" => uuid}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
data_record = EntityData.get!(uuid)
new_status =
case data_record.status do
"draft" -> "published"
"published" -> "archived"
"archived" -> "draft"
end
case EntityData.update_data(data_record, %{status: new_status}) do
{:ok, _updated_data} ->
socket =
socket
|> refresh_data_stats()
|> apply_filters()
|> put_flash(
:info,
gettext("Status updated to %{status}", status: status_label(new_status))
)
{:noreply, socket}
{:error, _changeset} ->
socket = put_flash(socket, :error, gettext("Failed to update status"))
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, gettext("Not authorized"))}
end
end
def handle_event("toggle_select", %{"uuid" => uuid}, socket) do
selected = socket.assigns.selected_uuids
selected =
if MapSet.member?(selected, uuid),
do: MapSet.delete(selected, uuid),
else: MapSet.put(selected, uuid)
{:noreply, assign(socket, :selected_uuids, selected)}
end
def handle_event("select_all", _params, socket) do
all_uuids = socket.assigns.entity_data_records |> Enum.map(& &1.uuid) |> MapSet.new()
{:noreply, assign(socket, :selected_uuids, all_uuids)}
end
def handle_event("deselect_all", _params, socket) do
{:noreply, assign(socket, :selected_uuids, MapSet.new())}
end
def handle_event("bulk_action", %{"action" => "archive"}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
uuids = socket.assigns.selected_uuids
if MapSet.size(uuids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_update_status(MapSet.to_list(uuids), "archived")
{:noreply,
socket
|> assign(:selected_uuids, MapSet.new())
|> refresh_data_stats()
|> apply_filters()
|> put_flash(:info, gettext("%{count} records archived", count: count))}
end
else
{:noreply, put_flash(socket, :error, gettext("Not authorized"))}
end
end
def handle_event("bulk_action", %{"action" => "restore"}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
uuids = socket.assigns.selected_uuids
if MapSet.size(uuids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_update_status(MapSet.to_list(uuids), "published")
{:noreply,
socket
|> assign(:selected_uuids, MapSet.new())
|> refresh_data_stats()
|> apply_filters()
|> put_flash(:info, gettext("%{count} records restored", count: count))}
end
else
{:noreply, put_flash(socket, :error, gettext("Not authorized"))}
end
end
def handle_event("bulk_action", %{"action" => "delete"}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
uuids = socket.assigns.selected_uuids
if MapSet.size(uuids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_delete(MapSet.to_list(uuids))
{:noreply,
socket
|> assign(:selected_uuids, MapSet.new())
|> refresh_data_stats()
|> apply_filters()
|> put_flash(:info, gettext("%{count} records deleted", count: count))}
end
else
{:noreply, put_flash(socket, :error, gettext("Not authorized"))}
end
end
def handle_event("bulk_action", %{"action" => "change_status", "status" => status}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
uuids = socket.assigns.selected_uuids
if MapSet.size(uuids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_update_status(MapSet.to_list(uuids), status)
{:noreply,
socket
|> assign(:selected_uuids, MapSet.new())
|> refresh_data_stats()
|> apply_filters()
|> put_flash(:info, gettext("%{count} records updated", count: count))}
end
else
{:noreply, put_flash(socket, :error, gettext("Not authorized"))}
end
end
## Live updates
def handle_info({:entity_created, _entity_uuid}, socket) do
{:noreply, refresh_entities_and_data(socket)}
end
def handle_info({:entity_updated, entity_uuid}, socket) do
# If the currently viewed entity was updated, check if it was archived
if socket.assigns.selected_entity_uuid && entity_uuid == socket.assigns.selected_entity_uuid do
entity = Entities.get_entity!(entity_uuid)
# 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
# Update the selected entity and page title with fresh data
socket =
socket
|> assign(:selected_entity, entity)
|> assign(:page_title, entity.display_name)
|> refresh_entities_and_data()
{:noreply, socket}
end
else
{:noreply, refresh_entities_and_data(socket)}
end
end
def handle_info({:entity_deleted, entity_uuid}, socket) do
# If the currently viewed entity was deleted, redirect to entities list
if socket.assigns.selected_entity_uuid && entity_uuid == socket.assigns.selected_entity_uuid do
{:noreply,
socket
|> put_flash(:error, gettext("Entity was deleted in another session."))
|> redirect(to: Routes.path("/admin/entities", locale: socket.assigns.current_locale_base))}
else
{:noreply, refresh_entities_and_data(socket)}
end
end
def handle_info({event, _entity_uuid, _data_uuid}, socket)
when event in [:data_created, :data_updated, :data_deleted] do
socket =
socket
|> refresh_data_stats()
|> apply_filters()
{:noreply, socket}
end
def handle_info({:data_reordered, _entity_uuid}, socket) do
{:noreply, apply_filters(socket)}
end
# Helper Functions
defp build_base_path(nil), do: "/admin/entities"
defp build_base_path(entity_uuid) when is_binary(entity_uuid) do
case Entities.get_entity(entity_uuid) do
nil -> "/admin/entities"
entity -> "/admin/entities/#{entity.name}/data"
end
end
defp build_url_params(_entity_uuid, status, search_term, view_mode) do
params = []
# Don't include entity_uuid in query params since it's in the path
params =
if status && status != "all" do
[{"status", status} | params]
else
params
end
params =
if search_term && String.trim(search_term) != "" do
[{"search", search_term} | params]
else
params
end
params =
if view_mode && view_mode != "table" do
[{"view", view_mode} | params]
else
params
end
URI.encode_query(params)
end
defp apply_filters(socket) do
entity = socket.assigns[:selected_entity]
entity_uuid = socket.assigns[:selected_entity_uuid]
status = socket.assigns[:selected_status] || "all"
search_term = socket.assigns[:search_term] || ""
# Pass sort_mode from the already-loaded entity to avoid redundant DB lookups
sort_opts =
if entity, do: [sort_mode: Entities.get_sort_mode(entity)], else: []
entity_data_records =
fetch_records(entity_uuid, status, sort_opts)
|> filter_by_search(search_term)
assign(socket, :entity_data_records, entity_data_records)
end
# When an entity is selected, use sort-mode-aware queries
defp fetch_records(nil, "all", _opts), do: EntityData.list_all_data()
defp fetch_records(nil, status, _opts), do: EntityData.list_data_by_status(status)
defp fetch_records(entity_uuid, "all", opts),
do: EntityData.list_by_entity(entity_uuid, opts)
defp fetch_records(entity_uuid, status, opts),
do: EntityData.list_by_entity_and_status(entity_uuid, status, opts)
defp filter_by_search(records, ""), do: records
defp filter_by_search(records, search_term) do
search_term_lower = String.downcase(String.trim(search_term))
Enum.filter(records, fn record ->
title_match = String.contains?(String.downcase(record.title || ""), search_term_lower)
slug_match = String.contains?(String.downcase(record.slug || ""), search_term_lower)
title_match || slug_match
end)
end
defp refresh_data_stats(socket) do
stats = EntityData.get_data_stats(socket.assigns.selected_entity_uuid)
socket
|> assign(:total_records, stats.total_records)
|> assign(:published_records, stats.published_records)
|> assign(:draft_records, stats.draft_records)
|> assign(:archived_records, stats.archived_records)
end
defp refresh_entities_and_data(socket) do
socket
|> assign(:entities, Entities.list_entities())
|> refresh_data_stats()
|> apply_filters()
end
def status_badge_class(status) do
case status do
"published" -> "badge-success"
"draft" -> "badge-warning"
"archived" -> "badge-neutral"
_ -> "badge-outline"
end
end
def status_label(status) do
case status do
"published" -> gettext("Published")
"draft" -> gettext("Draft")
"archived" -> gettext("Archived")
_ -> gettext("Unknown")
end
end
def status_icon(status) do
case status do
"published" -> "hero-check-circle"
"draft" -> "hero-pencil"
"archived" -> "hero-archive-box"
_ -> "hero-question-mark-circle"
end
end
def get_entity_name(entities, entity_uuid) do
case Enum.find(entities, &(&1.uuid == entity_uuid)) do
nil -> gettext("Unknown")
entity -> entity.display_name
end
end
def get_entity_slug(entities, entity_uuid) do
case Enum.find(entities, &(&1.uuid == entity_uuid)) do
nil -> ""
entity -> entity.name
end
end
def truncate_text(text, length \\ 100)
def truncate_text(text, length) when is_binary(text) do
if String.length(text) > length do
String.slice(text, 0, length) <> "..."
else
text
end
end
def truncate_text(_, _), do: ""
def format_data_preview(data) when is_map(data) do
# For multilang data, show primary language fields
display_data =
if Multilang.multilang_data?(data) do
Multilang.flatten_to_primary(data)
else
data
end
display_data
|> Enum.take(3)
|> Enum.map_join(" • ", fn {key, value} ->
"#{key}: #{truncate_text(to_string(value), 30)}"
end)
end
def format_data_preview(_), do: ""
end