Packages
phoenix_kit
1.7.78
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/db/web/show.ex
defmodule PhoenixKit.Modules.DB.Web.Show do
@moduledoc """
Table detail view with paginated row browsing.
Supports live updates via PostgreSQL LISTEN/NOTIFY - when data in the
viewed table changes, the view refreshes automatically.
"""
use PhoenixKitWeb, :live_view
alias PhoenixKit.Modules.DB
alias PhoenixKit.Modules.DB.Listener
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
@default_per_page 20
@allowed_per_page [10, 20, 50, 100, 200]
@impl true
def mount(%{"schema" => schema, "table" => table} = params, _session, socket) do
locale = params["locale"] || Routes.get_default_admin_locale()
page = parse_page(params["page"])
per_page = parse_per_page(params["per_page"])
# Set up live updates if connected
if connected?(socket) do
# Subscribe to changes for this table
Listener.subscribe(schema, table)
# Ensure the trigger is set up for this table
DB.ensure_trigger(schema, table)
end
preview = DB.table_preview(schema, table, %{page: page, per_page: per_page})
socket =
socket
|> assign(:page_title, "#{schema}.#{table}")
|> assign(:project_title, Settings.get_project_title())
|> assign(:current_locale, locale)
|> assign(
:current_path,
Routes.path("/admin/db/#{schema}/#{table}", locale: locale)
)
|> assign(:schema, schema)
|> assign(:table, table)
|> assign(:per_page, per_page)
|> assign(:preview, preview)
|> assign(:highlighted_rows, [])
|> assign(:refresh_scheduled, false)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
page = parse_page(params["page"])
per_page = parse_per_page(params["per_page"])
preview =
DB.table_preview(
socket.assigns.schema,
socket.assigns.table,
%{page: page, per_page: per_page}
)
{:noreply,
socket
|> assign(:per_page, per_page)
|> assign(:preview, preview)}
end
@impl true
def handle_event("change_page", %{"page" => page}, socket) do
page = parse_page(page)
{:noreply, push_patch(socket, to: build_path(socket, %{page: page}))}
end
@impl true
def handle_event("set_per_page", %{"per_page" => per_page}, socket) do
per_page = parse_per_page(per_page)
# Recalculate page to keep roughly the same position
current_row = (socket.assigns.preview.page - 1) * socket.assigns.per_page
new_page = max(1, div(current_row, per_page) + 1)
{:noreply, push_patch(socket, to: build_path(socket, %{per_page: per_page, page: new_page}))}
end
# Debounce interval for live updates (ms)
@refresh_debounce_ms 1000
# Handle live updates from PostgreSQL NOTIFY
@impl true
def handle_info({:table_changed, schema, table, _operation, _row_id}, socket) do
# Only refresh if this is the table we're viewing
if schema == socket.assigns.schema and table == socket.assigns.table do
# Debounce: schedule a refresh instead of doing it immediately
# This prevents hammering the database on very active tables
socket = schedule_debounced_refresh(socket)
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true
def handle_info(:debounced_refresh, socket) do
# Clear the pending refresh flag and do the actual refresh
socket = assign(socket, :refresh_scheduled, false)
old_preview = socket.assigns.preview
old_row_count = old_preview.row_count
new_preview =
DB.table_preview(socket.assigns.schema, socket.assigns.table, %{
page: old_preview.page,
per_page: socket.assigns.per_page
})
# Detect what changed
{added_count, removed_count, changed_on_page} =
detect_changes(old_preview.rows, new_preview.rows, old_row_count, new_preview.row_count)
# Find which rows on current page are new/changed for highlighting
highlighted_ids = find_new_or_changed_rows(old_preview.rows, new_preview.rows)
# Build notification message
socket = add_change_notification(socket, added_count, removed_count, changed_on_page)
socket =
socket
|> assign(:preview, new_preview)
|> assign(:highlighted_rows, highlighted_ids)
# Schedule highlight removal after 3 seconds
if highlighted_ids != [] do
Process.send_after(self(), :clear_highlights, 3000)
end
{:noreply, socket}
end
@impl true
def handle_info(:clear_highlights, socket) do
{:noreply, assign(socket, :highlighted_rows, [])}
end
# Schedule a debounced refresh - only schedules if one isn't already pending
defp schedule_debounced_refresh(socket) do
if socket.assigns[:refresh_scheduled] do
# Already scheduled, skip
socket
else
Process.send_after(self(), :debounced_refresh, @refresh_debounce_ms)
assign(socket, :refresh_scheduled, true)
end
end
def format_cell(value) when is_map(value), do: Jason.encode!(value)
def format_cell(value) when is_list(value), do: inspect(value)
def format_cell(value) when is_binary(value) do
if String.valid?(value), do: value, else: inspect(value)
end
def format_cell(value), do: to_string(value || "")
# Parse and validate page number - must be positive integer
defp parse_page(nil), do: 1
defp parse_page(value) when is_integer(value) and value > 0, do: value
defp parse_page(value) when is_binary(value) do
case Integer.parse(value) do
{int, ""} when int > 0 -> int
_ -> 1
end
end
defp parse_page(_), do: 1
# Parse and validate per_page - must be one of the allowed values
defp parse_per_page(nil), do: @default_per_page
defp parse_per_page(value) when is_integer(value) and value in @allowed_per_page, do: value
defp parse_per_page(value) when is_binary(value) do
case Integer.parse(value) do
{int, ""} when int in @allowed_per_page -> int
_ -> @default_per_page
end
end
defp parse_per_page(_), do: @default_per_page
defp build_path(socket, overrides) do
# Normalize overrides to string keys
overrides = Map.new(overrides, fn {k, v} -> {to_string(k), v} end)
params =
%{
"page" => socket.assigns.preview.page,
"per_page" => socket.assigns.per_page
}
|> Map.merge(overrides)
|> Enum.reject(fn {k, v} ->
v in [nil, ""] or
(k == "page" and v in [1, "1"]) or
(k == "per_page" and v in [@default_per_page, to_string(@default_per_page)])
end)
|> Map.new()
base =
Routes.path("/admin/db/#{socket.assigns.schema}/#{socket.assigns.table}",
locale: socket.assigns.current_locale
)
if map_size(params) == 0 do
base
else
base <> "?" <> URI.encode_query(params)
end
end
# Detect overall changes between old and new data
defp detect_changes(old_rows, new_rows, old_count, new_count) do
added_count = max(0, new_count - old_count)
removed_count = max(0, old_count - new_count)
# Check if rows on current page changed
changed_on_page = rows_changed_on_page?(old_rows, new_rows)
{added_count, removed_count, changed_on_page}
end
defp rows_changed_on_page?(old_rows, new_rows) do
# Simple comparison - if row count or content differs
length(old_rows) != length(new_rows) or old_rows != new_rows
end
# Find rows that are new or changed on the current page
# Returns list of row identifiers (using "id" column if available, or row index)
defp find_new_or_changed_rows(old_rows, new_rows) do
old_by_id = rows_by_identifier(old_rows)
new_by_id = rows_by_identifier(new_rows)
# Find new rows (in new but not in old)
new_ids =
new_by_id
|> Map.keys()
|> Enum.filter(fn id -> not Map.has_key?(old_by_id, id) end)
# Find changed rows (same id but different content)
changed_ids =
new_by_id
|> Enum.filter(fn {id, row} ->
case Map.get(old_by_id, id) do
nil -> false
old_row -> old_row != row
end
end)
|> Enum.map(fn {id, _} -> id end)
new_ids ++ changed_ids
end
# Build a map of rows by their identifier (id column or stringified row)
defp rows_by_identifier(rows) do
rows
|> Enum.with_index()
|> Enum.map(fn {row, idx} ->
# Use "id" column if available, otherwise use index
id = Map.get(row, "id") || Map.get(row, :id) || "idx_#{idx}"
{id, row}
end)
|> Map.new()
end
# Add a flash notification about changes
defp add_change_notification(socket, 0, 0, false), do: socket
defp add_change_notification(socket, added, removed, changed_on_page) do
messages = []
messages =
if added > 0 do
messages ++ ["#{added} row#{if added > 1, do: "s", else: ""} added"]
else
messages
end
messages =
if removed > 0 do
messages ++ ["#{removed} row#{if removed > 1, do: "s", else: ""} removed"]
else
messages
end
messages =
if changed_on_page and added == 0 and removed == 0 do
messages ++ ["Data updated"]
else
messages
end
if messages != [] do
put_flash(socket, :info, Enum.join(messages, ", "))
else
socket
end
end
# Check if a row should be highlighted
def row_highlighted?(row, highlighted_rows) do
id = Map.get(row, "id") || Map.get(row, :id)
id != nil and id in highlighted_rows
end
end