Packages
phoenix_kit
1.7.42
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.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()
# Get entity from route params using slug (entity_slug or entity_id for backwards compat)
{entity, entity_id} =
case params["entity_slug"] || params["entity_id"] do
nil ->
{nil, nil}
slug when is_binary(slug) ->
# Try to get entity by name (slug)
case Entities.get_entity_by_name(slug) do
nil -> {nil, nil}
entity -> {entity, entity.id}
end
end
# Get stats filtered by entity if one is selected
stats = EntityData.get_data_stats(entity_id)
# Set page title based on entity
page_title =
if entity do
entity.display_name
else
gettext("Data Navigator")
end
socket =
socket
|> assign(:page_title, page_title)
|> assign(:project_title, project_title)
|> assign(:entities, entities)
|> assign(:total_records, stats.total_records)
|> assign(:published_records, stats.published_records)
|> assign(:draft_records, stats.draft_records)
|> assign(:archived_records, stats.archived_records)
|> assign(:selected_entity, entity)
|> assign(:selected_entity_id, entity_id)
|> assign(:selected_status, "all")
|> assign(:selected_category, "all")
|> assign(:selected_ids, MapSet.new())
|> assign(:available_categories, [])
|> assign(:search_term, "")
|> assign(:view_mode, "table")
|> apply_filters()
if connected?(socket) && entity_id do
Events.subscribe_to_entity_data(entity_id)
end
{:ok, socket}
end
def handle_params(params, _url, socket) do
# Get entity from slug in params (entity_slug or entity_id for backwards compat)
{entity, entity_id} = resolve_entity_from_params(params, socket)
# Recalculate stats and subscribe if entity changed
socket = maybe_update_entity_stats(socket, entity_id)
# Extract filter params with defaults
status = params["status"] || "all"
category = params["category"] || "all"
search_term = params["search"] || ""
view_mode = params["view"] || "table"
socket =
socket
|> assign(:selected_entity, entity)
|> assign(:selected_entity_id, entity_id)
|> assign(:selected_status, status)
|> assign(:selected_category, category)
|> assign(:search_term, search_term)
|> assign(:view_mode, view_mode)
|> apply_filters()
{:noreply, socket}
end
# Resolve entity and entity_id 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_id}
"" ->
{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.id}
end
end
# Update entity stats and subscribe to events if entity changed
defp maybe_update_entity_stats(socket, new_entity_id) do
if new_entity_id != socket.assigns.selected_entity_id do
maybe_subscribe_to_entity(socket, new_entity_id)
update_entity_stats(socket, new_entity_id)
else
socket
end
end
# Subscribe to entity data events if connected
defp maybe_subscribe_to_entity(socket, entity_id) do
if connected?(socket) && entity_id do
Events.subscribe_to_entity_data(entity_id)
end
end
# Update socket with fresh entity statistics
defp update_entity_stats(socket, entity_id) do
stats = EntityData.get_data_stats(entity_id)
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_id,
socket.assigns.selected_status,
socket.assigns.selected_category,
socket.assigns.search_term,
mode
)
path = build_base_path(socket.assigns.selected_entity_id)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:view_mode, mode)
|> assign(:selected_ids, 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_id" => ""}, 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_id" => entity_id}, socket) do
params =
build_url_params(
entity_id,
socket.assigns.selected_status,
socket.assigns.selected_category,
socket.assigns.search_term,
socket.assigns.view_mode
)
path = build_base_path(entity_id)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_ids, 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_id,
status,
socket.assigns.selected_category,
socket.assigns.search_term,
socket.assigns.view_mode
)
path = build_base_path(socket.assigns.selected_entity_id)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_ids, MapSet.new())
|> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base))
{:noreply, socket}
end
def handle_event("filter_by_category", %{"category" => category}, socket) do
params =
build_url_params(
socket.assigns.selected_entity_id,
socket.assigns.selected_status,
category,
socket.assigns.search_term,
socket.assigns.view_mode
)
path = build_base_path(socket.assigns.selected_entity_id)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_ids, 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_id,
socket.assigns.selected_status,
socket.assigns.selected_category,
term,
socket.assigns.view_mode
)
path = build_base_path(socket.assigns.selected_entity_id)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_ids, 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_id,
"all",
"all",
"",
socket.assigns.view_mode
)
path = build_base_path(socket.assigns.selected_entity_id)
full_path = if params != "", do: "#{path}?#{params}", else: path
socket =
socket
|> assign(:selected_ids, MapSet.new())
|> push_patch(to: Routes.locale_aware_path(socket.assigns, full_path))
{: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_ids
selected =
if MapSet.member?(selected, uuid),
do: MapSet.delete(selected, uuid),
else: MapSet.put(selected, uuid)
{:noreply, assign(socket, :selected_ids, 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_ids, all_uuids)}
end
def handle_event("deselect_all", _params, socket) do
{:noreply, assign(socket, :selected_ids, MapSet.new())}
end
def handle_event("bulk_action", %{"action" => "archive"}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
ids = socket.assigns.selected_ids
if MapSet.size(ids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_update_status(MapSet.to_list(ids), "archived")
{:noreply,
socket
|> assign(:selected_ids, 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
ids = socket.assigns.selected_ids
if MapSet.size(ids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_update_status(MapSet.to_list(ids), "published")
{:noreply,
socket
|> assign(:selected_ids, 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
ids = socket.assigns.selected_ids
if MapSet.size(ids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_delete(MapSet.to_list(ids))
{:noreply,
socket
|> assign(:selected_ids, 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_category", "category" => category},
socket
) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
ids = socket.assigns.selected_ids
if MapSet.size(ids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_update_category(MapSet.to_list(ids), category)
{:noreply,
socket
|> assign(:selected_ids, 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
def handle_event("bulk_action", %{"action" => "change_status", "status" => status}, socket) do
if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
ids = socket.assigns.selected_ids
if MapSet.size(ids) == 0 do
{:noreply, put_flash(socket, :error, gettext("No records selected"))}
else
{count, _} = EntityData.bulk_update_status(MapSet.to_list(ids), status)
{:noreply,
socket
|> assign(:selected_ids, 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_id}, socket) do
{:noreply, refresh_entities_and_data(socket)}
end
def handle_info({:entity_updated, entity_id}, socket) do
# If the currently viewed entity was updated, check if it was archived
if socket.assigns.selected_entity_id && entity_id == socket.assigns.selected_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
# 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_id}, socket) do
# If the currently viewed entity was deleted, redirect to entities list
if socket.assigns.selected_entity_id && entity_id == socket.assigns.selected_entity_id 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_id, _data_id}, socket)
when event in [:data_created, :data_updated, :data_deleted] do
socket =
socket
|> refresh_data_stats()
|> apply_filters()
{:noreply, socket}
end
# Helper Functions
defp build_base_path(nil), do: "/admin/entities"
defp build_base_path(entity_id) when is_integer(entity_id) do
# Get entity by ID to get its slug
case Entities.get_entity!(entity_id) do
nil -> "/admin/entities"
entity -> "/admin/entities/#{entity.name}/data"
end
end
defp build_url_params(_entity_id, status, category, search_term, view_mode) do
params = []
# Don't include entity_id in query params since it's in the path
params =
if status && status != "all" do
[{"status", status} | params]
else
params
end
params =
if category && category != "all" do
[{"category", category} | 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_id = socket.assigns[:selected_entity_id]
status = socket.assigns[:selected_status] || "all"
category = socket.assigns[:selected_category] || "all"
search_term = socket.assigns[:search_term] || ""
# Apply entity and status filters first
pre_category_records =
EntityData.list_all_data()
|> filter_by_entity(entity_id)
|> filter_by_status(status)
# Extract categories BEFORE category filter so dropdown always shows all options
available_categories = EntityData.extract_unique_categories(pre_category_records)
# Then apply remaining filters
entity_data_records =
pre_category_records
|> filter_by_category(category)
|> filter_by_search(search_term)
socket
|> assign(:entity_data_records, entity_data_records)
|> assign(:available_categories, available_categories)
end
defp filter_by_entity(records, nil), do: records
defp filter_by_entity(records, entity_id) do
Enum.filter(records, fn record -> record.entity_id == entity_id end)
end
defp filter_by_status(records, "all"), do: records
defp filter_by_status(records, status) do
Enum.filter(records, fn record -> record.status == status end)
end
defp filter_by_category(records, "all"), do: records
defp filter_by_category(records, "uncategorized") do
Enum.filter(records, fn record ->
cat = get_in(record.data, ["category"])
is_nil(cat) || cat == ""
end)
end
defp filter_by_category(records, category) do
Enum.filter(records, fn record ->
get_in(record.data, ["category"]) == category
end)
end
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)
category_match =
case get_in(record.data, ["category"]) do
nil -> false
cat -> String.contains?(String.downcase(cat), search_term_lower)
end
title_match || slug_match || category_match
end)
end
defp refresh_data_stats(socket) do
stats = EntityData.get_data_stats(socket.assigns.selected_entity_id)
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_id) do
case Enum.find(entities, &(&1.id == entity_id)) do
nil -> gettext("Unknown")
entity -> entity.display_name
end
end
def get_entity_slug(entities, entity_id) do
case Enum.find(entities, &(&1.id == entity_id)) 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
# Show first few key-value pairs as preview
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