Packages
phoenix_kit
1.7.161
1.7.210
1.7.209
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/phoenix_kit_web/live/activity/index.ex
defmodule PhoenixKitWeb.Live.Activity.Index do
@moduledoc """
Admin LiveView for the activity feed.
Shows a real-time stream of business-level actions across the platform
with filtering by action type, user, resource type, and date range.
"""
use PhoenixKitWeb, :live_view
alias PhoenixKit.Activity
alias PhoenixKit.PubSub.Manager, as: PubSubManager
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Users.Auth.User
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
alias PhoenixKit.Utils.Values
# Per-admin grid/list preference for the activity table, persisted in the
# current user's custom_fields (mirrors the users table view toggle).
@view_mode_key "activity_view_mode"
@impl true
def mount(_params, _session, socket) do
scope = socket.assigns[:phoenix_kit_current_scope]
if scope && Scope.has_module_access?(scope, "dashboard") do
if connected?(socket) do
PubSubManager.subscribe(Activity.pubsub_topic())
end
project_title = Settings.get_project_title()
socket =
socket
|> assign(:page_title, gettext("Activity"))
|> assign(:project_title, project_title)
|> assign(:modules, Activity.list_modules())
|> assign(:modes, Activity.list_modes())
|> assign(:action_types, Activity.list_action_types())
|> assign(:resource_types, Activity.list_resource_types())
|> assign(:view_mode, load_user_view_mode(socket.assigns[:phoenix_kit_current_user]))
|> assign_filter_defaults()
|> load_activities()
{:ok, socket}
else
{:ok,
socket
|> put_flash(:error, gettext("Access denied"))
|> push_navigate(to: Routes.path("/admin"))}
end
end
@impl true
def handle_params(params, url, socket) do
socket =
socket
|> assign(:url_path, URI.parse(url).path)
|> apply_params(params)
|> load_activities()
{:noreply, socket}
end
@impl true
def handle_event("filter", params, socket) do
filter_params = Map.get(params, "filter", %{})
new_params =
%{"page" => "1"}
|> maybe_put("module", filter_params["module"])
|> maybe_put("mode", filter_params["mode"])
|> maybe_put("action", filter_params["action"])
|> maybe_put("resource_type", filter_params["resource_type"])
# No form input for resource_uuid — it's a URL-driven scope (e.g. a
# "view this resource's activity" deep link); preserve it across filter
# changes so tweaking module/action doesn't drop the resource scope.
|> maybe_put("resource_uuid", socket.assigns.filter_resource_uuid)
query = URI.encode_query(new_params)
{:noreply, push_patch(socket, to: Routes.path("/admin/activity?#{query}"))}
end
@impl true
def handle_event("clear_filters", _params, socket) do
{:noreply, push_patch(socket, to: Routes.path("/admin/activity"))}
end
@impl true
def handle_event("set_view_mode", %{"mode" => mode}, socket) when mode in ["card", "table"] do
user = persist_user_view_mode(socket.assigns[:phoenix_kit_current_user], mode)
{:noreply,
socket
|> assign(:phoenix_kit_current_user, user)
|> assign(:view_mode, mode)}
end
@impl true
def handle_info({:activity_logged, _entry}, socket) do
# Reload on new activity (real-time update)
{:noreply, load_activities(socket)}
end
## Private
defp assign_filter_defaults(socket) do
socket
|> assign(:page, 1)
|> assign(:per_page, 50)
|> assign(:filter_module, nil)
|> assign(:filter_mode, nil)
|> assign(:filter_action, nil)
|> assign(:filter_resource_type, nil)
|> assign(:filter_resource_uuid, nil)
end
defp apply_params(socket, params) do
socket
|> assign(:page, parse_int(params["page"], 1))
|> assign(:filter_module, Values.blank_to_nil(params["module"]))
|> assign(:filter_mode, Values.blank_to_nil(params["mode"]))
|> assign(:filter_action, Values.blank_to_nil(params["action"]))
|> assign(:filter_resource_type, Values.blank_to_nil(params["resource_type"]))
|> assign(:filter_resource_uuid, Values.blank_to_nil(params["resource_uuid"]))
end
defp load_activities(socket) do
result =
Activity.list(
page: socket.assigns.page,
per_page: socket.assigns.per_page,
module: socket.assigns.filter_module,
mode: socket.assigns.filter_mode,
action: socket.assigns.filter_action,
resource_type: socket.assigns.filter_resource_type,
resource_uuid: socket.assigns.filter_resource_uuid,
preload: [:actor, :target]
)
resource_users = Activity.resolve_resource_users(result.entries)
socket
|> assign(:entries, result.entries)
|> assign(:resource_users, resource_users)
|> assign(:total, result.total)
|> assign(:total_pages, result.total_pages)
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
# Per-user table/card preference, defaulting to "table". Mirrors the users
# table; persisted in the current user's custom_fields.
defp load_user_view_mode(%User{} = user) do
case Auth.get_user_field(user, @view_mode_key) do
mode when mode in ["card", "table"] -> mode
_ -> "table"
end
end
defp load_user_view_mode(_), do: "table"
defp persist_user_view_mode(%{uuid: uuid} = user, mode) when is_binary(uuid) do
fresh = Auth.get_user(uuid) || user
merged = Map.put(fresh.custom_fields || %{}, @view_mode_key, mode)
# Internal view preference: skip the custom-field-definition registration
# (so it never surfaces in the column customizer) and the profile-update
# broadcast (so toggling the view doesn't reload the users list for every
# admin). Mirrors the users table.
case Auth.update_user_custom_fields(fresh, merged,
ensure_definitions: false,
broadcast: false
) do
{:ok, updated} -> updated
{:error, _} -> user
end
end
defp persist_user_view_mode(user, _mode), do: user
# Build a filtered activity path, merging the current filters with `overrides`
# (a keyword list like `[module: "posts"]`; pass `""` to clear a filter). Used
# by the toolbar filter dropdowns so picking one filter preserves the others.
defp filter_path(assigns, overrides) do
query =
%{
"module" => assigns[:filter_module],
"mode" => assigns[:filter_mode],
"action" => assigns[:filter_action],
"resource_type" => assigns[:filter_resource_type],
# Preserve the per-resource deep-link scope (#599) when picking a filter.
"resource_uuid" => assigns[:filter_resource_uuid]
}
|> Map.merge(Map.new(overrides, fn {k, v} -> {to_string(k), v} end))
|> Enum.reject(fn {_k, v} -> v in [nil, ""] end)
|> URI.encode_query()
Routes.path("/admin/activity" <> if(query == "", do: "", else: "?#{query}"))
end
defp parse_int(nil, default), do: default
defp parse_int(str, default) when is_binary(str) do
case Integer.parse(str) do
{n, _} -> max(n, 1)
:error -> default
end
end
defp action_badge_color(action), do: Activity.action_badge_color(action)
defp mode_badge_color(mode), do: Activity.mode_badge_color(mode)
# True when any of the four Activity filters is set. Drives both the
# toolbar Clear-filters button and the filtered empty-state message.
defp any_filter_active?(assigns) do
[
assigns.filter_module,
assigns.filter_mode,
assigns.filter_action,
assigns.filter_resource_type
]
|> Enum.any?(&(&1 not in [nil, ""]))
end
defp summarize_details(metadata) do
meta = metadata || %{}
if meta["added"] || meta["removed"] do
# For role updates, show added/removed summary
parts = []
parts = if meta["added"], do: parts ++ ["added: #{meta["added"]}"], else: parts
parts = if meta["removed"], do: parts ++ ["removed: #{meta["removed"]}"], else: parts
Enum.join(parts, ", ")
else
# For profile updates, extract field names from _from/_to pairs
changed_fields =
meta
|> Map.keys()
|> Enum.filter(&String.ends_with?(&1, "_to"))
|> Enum.map(&String.trim_trailing(&1, "_to"))
|> Enum.reject(&(&1 == ""))
if changed_fields != [] do
fields = Enum.map_join(changed_fields, ", ", &String.replace(&1, "_", " "))
"#{fields} updated"
else
summarize_remaining_meta(meta)
end
end
end
defp summarize_remaining_meta(meta) do
meta
|> Map.drop(["method", "actor_role"])
|> Enum.reject(fn {_k, v} -> v == nil or v == "" end)
|> case do
[] -> nil
entries -> Enum.map_join(entries, ", ", fn {k, v} -> "#{k}: #{v}" end)
end
end
end