Current section

Files

Jump to
phoenix_kit_emails lib phoenix_kit modules emails web templates.ex
Raw

lib/phoenix_kit/modules/emails/web/templates.ex

defmodule PhoenixKit.Modules.Emails.Web.Templates do
@moduledoc """
LiveView for displaying and managing email templates in PhoenixKit admin panel.
Provides comprehensive template management interface with filtering, searching,
creation, editing, and analytics for email templates.
## Features
- **Real-time Template List**: Live updates of templates
- **Advanced Filtering**: By category, status, system vs custom
- **Search Functionality**: Search across template names, descriptions
- **Template Management**: Create, edit, clone, archive templates
- **Usage Analytics**: View template usage statistics
- **Test Send**: Send test emails using templates
- **System Templates**: Manage core system templates
## Route
This LiveView is mounted at `{prefix}/admin/emails/templates` and requires
appropriate admin permissions.
Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`).
The route is auto-generated by PhoenixKit from `admin_tabs/0` at compile time and
injected into `live_session :phoenix_kit_admin`. Do not declare this route in your
parent app's `router.ex`. See `phoenix_kit/guides/custom-admin-pages.md`.
## Permissions
Access is restricted to users with admin or owner roles in PhoenixKit.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKit.Modules.Emails.Gettext
import PhoenixKitWeb.Components.Core.Icon
import PhoenixKitWeb.Components.Core.TableDefault
import PhoenixKitWeb.Components.Core.Badge
import PhoenixKitWeb.Components.Core.Pagination
alias PhoenixKit.Modules.Emails.Template
alias PhoenixKit.Modules.Emails.Templates
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
@default_per_page 25
@max_per_page 100
## --- Lifecycle Callbacks ---
@impl true
def mount(_params, _session, socket) do
# Get project title from settings
socket =
socket
|> assign(:page_title, gettext("Email Templates"))
|> assign(:page_subtitle, gettext("Manage and organize your email templates"))
|> assign(:templates, [])
|> assign(:total_count, 0)
|> assign(:stats, %{})
|> assign(:loading, true)
|> assign(:show_clone_modal, false)
|> assign(:clone_template, nil)
|> assign(:clone_form, %{name: "", display_name: "", errors: %{}})
|> assign(:confirmation_modal, %{show: false})
|> assign(:display_locale, Settings.get_content_language() || "en")
|> assign(:sort_by, :inserted_at)
|> assign(:sort_dir, :desc)
|> assign_filter_defaults()
|> assign_pagination_defaults()
{:ok, socket}
end
@impl true
def handle_params(params, _url, socket) do
socket =
socket
|> apply_params(params)
|> load_templates()
|> load_stats()
{:noreply, socket}
end
## --- Event Handlers ---
@impl true
def handle_event("filter", params, socket) do
# Handle both search and filter parameters
combined_params = %{}
# Extract search parameters
combined_params =
case Map.get(params, "search") do
%{"query" => query} -> Map.put(combined_params, "search", String.trim(query || ""))
_ -> combined_params
end
# Extract filter parameters
combined_params =
case Map.get(params, "filter") do
filter_params when is_map(filter_params) -> Map.merge(combined_params, filter_params)
_ -> combined_params
end
# Reset to first page when filtering
combined_params = Map.put(combined_params, "page", "1")
# Build new URL parameters
new_params = build_url_params(socket.assigns, combined_params)
{:noreply,
socket
|> push_patch(to: Routes.path("/admin/emails/templates?#{new_params}"))}
end
@impl true
def handle_event("clear_filters", _params, socket) do
{:noreply,
socket
|> push_patch(to: Routes.path("/admin/emails/templates"))}
end
@impl true
def handle_event("clear_search", _params, socket) do
# Clear only the search term (the × inside the search box); keep the other
# filters and reset to page 1.
new_params = build_url_params(socket.assigns, %{"search" => "", "page" => "1"})
{:noreply, push_patch(socket, to: Routes.path("/admin/emails/templates?#{new_params}"))}
end
@impl true
def handle_event("set_filter", %{"field" => field, "value" => value}, socket) do
# Dropdown filter selection (category/status/is_system): set the chosen
# filter, reset to page 1, and patch. Empty value clears that filter.
new_params = build_url_params(socket.assigns, %{field => value, "page" => "1"})
{:noreply, push_patch(socket, to: Routes.path("/admin/emails/templates?#{new_params}"))}
end
@impl true
def handle_event("toggle_sort", %{"by" => by}, socket) do
# Clicking a sortable column header: validate the field, then toggle the
# direction if it's already the active sort, otherwise switch to that field
# ascending. Resets to page 1 and patches.
field = validate_sort_by(by)
sort_dir =
if field == socket.assigns.sort_by do
if socket.assigns.sort_dir == :asc, do: :desc, else: :asc
else
:asc
end
new_params =
build_url_params(socket.assigns, %{
"sort_by" => to_string(field),
"sort_dir" => to_string(sort_dir),
"page" => "1"
})
{:noreply, push_patch(socket, to: Routes.path("/admin/emails/templates?#{new_params}"))}
end
@impl true
def handle_event("refresh", _params, socket) do
{:noreply,
socket
|> assign(:loading, true)
|> load_templates()
|> load_stats()}
end
@impl true
def handle_event("show_clone_modal", %{"uuid" => template_uuid}, socket) do
case Templates.get_template(template_uuid) do
nil ->
{:noreply,
socket
|> put_flash(:error, gettext("Template not found"))}
template ->
{:noreply,
socket
|> assign(:show_clone_modal, true)
|> assign(:clone_template, template)
|> assign(:clone_form, %{
name: "#{template.name}_copy",
display_name: "#{Template.get_translation(template.display_name, "en")} (Copy)",
errors: %{}
})}
end
end
@impl true
def handle_event("hide_clone_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_clone_modal, false)
|> assign(:clone_template, nil)
|> assign(:clone_form, %{name: "", display_name: "", errors: %{}})}
end
@impl true
def handle_event("validate_clone", %{"clone" => clone_params}, socket) do
errors = validate_clone_form(clone_params)
form = %{
name: clone_params["name"] || "",
display_name: clone_params["display_name"] || "",
errors: errors
}
{:noreply, assign(socket, :clone_form, form)}
end
@impl true
def handle_event("clone_template", %{"clone" => clone_params}, socket) do
errors = validate_clone_form(clone_params)
if map_size(errors) == 0 and socket.assigns.clone_template do
case Templates.clone_template(
socket.assigns.clone_template,
String.trim(clone_params["name"]),
%{display_name: clone_params["display_name"]}
) do
{:ok, new_template} ->
{:noreply,
socket
|> assign(:show_clone_modal, false)
|> assign(:clone_template, nil)
|> put_flash(
:info,
gettext("Template cloned successfully as '%{name}'", name: new_template.name)
)
|> push_navigate(to: Routes.path("/admin/emails/templates/#{new_template.uuid}/edit"))}
{:error, _changeset} ->
{:noreply,
socket
|> put_flash(:error, gettext("Failed to clone template"))}
end
else
# Show validation errors
form = %{
name: clone_params["name"] || "",
display_name: clone_params["display_name"] || "",
errors: errors
}
{:noreply, assign(socket, :clone_form, form)}
end
end
@impl true
def handle_event("edit_template", %{"uuid" => template_uuid}, socket) do
{:noreply,
socket
|> push_navigate(to: Routes.path("/admin/emails/templates/#{template_uuid}/edit"))}
end
@impl true
def handle_event("archive_template", %{"uuid" => template_uuid}, socket) do
case Templates.get_template(template_uuid) do
nil ->
{:noreply,
socket
|> put_flash(:error, gettext("Template not found"))}
%Template{is_system: true} ->
{:noreply,
socket
|> put_flash(:error, gettext("System templates cannot be archived"))}
template ->
case Templates.archive_template(template) do
{:ok, _archived_template} ->
{:noreply,
socket
|> put_flash(
:info,
gettext("Template '%{name}' archived successfully", name: template.name)
)
|> load_templates()
|> load_stats()}
{:error, _changeset} ->
{:noreply,
socket
|> put_flash(:error, gettext("Failed to archive template"))}
end
end
end
@impl true
def handle_event("activate_template", %{"uuid" => template_uuid}, socket) do
case Templates.get_template(template_uuid) do
nil ->
{:noreply,
socket
|> put_flash(:error, gettext("Template not found"))}
template ->
case Templates.activate_template(template) do
{:ok, _activated_template} ->
{:noreply,
socket
|> put_flash(
:info,
gettext("Template '%{name}' activated successfully", name: template.name)
)
|> load_templates()
|> load_stats()}
{:error, _changeset} ->
{:noreply,
socket
|> put_flash(:error, gettext("Failed to activate template"))}
end
end
end
@impl true
def handle_event("request_delete", %{"uuid" => uuid, "name" => name}, socket) do
modal = %{
show: true,
title: "Confirm Delete",
message:
"Are you sure you want to delete template '#{name}'? This action cannot be undone.",
button_text: "Delete Template",
action: "delete_template",
uuid: uuid
}
{:noreply, assign(socket, :confirmation_modal, modal)}
end
@impl true
def handle_event("cancel_confirmation", _params, socket) do
{:noreply, assign(socket, :confirmation_modal, %{show: false})}
end
@impl true
def handle_event("confirm_action", %{"action" => "delete_template", "uuid" => uuid}, socket) do
socket = assign(socket, :confirmation_modal, %{show: false})
handle_event("delete_template", %{"uuid" => uuid}, socket)
end
@impl true
def handle_event("delete_template", %{"uuid" => template_uuid}, socket) do
case Templates.get_template(template_uuid) do
nil ->
{:noreply,
socket
|> put_flash(:error, gettext("Template not found"))}
%Template{is_system: true} ->
{:noreply,
socket
|> put_flash(:error, gettext("System templates cannot be deleted"))}
template ->
case Templates.delete_template(template) do
{:ok, _deleted_template} ->
{:noreply,
socket
|> put_flash(
:info,
gettext("Template '%{name}' deleted successfully", name: template.name)
)
|> load_templates()
|> load_stats()}
{:error, :system_template_protected} ->
{:noreply,
socket
|> put_flash(:error, gettext("System templates cannot be deleted"))}
{:error, _changeset} ->
{:noreply,
socket
|> put_flash(:error, gettext("Failed to delete template"))}
end
end
end
## --- Template ---
## --- Private Helper Functions ---
# Apply default filter values
defp assign_filter_defaults(socket) do
filters = %{
search: "",
category: "",
status: "",
is_system: ""
}
assign(socket, :filters, filters)
end
# Apply default pagination values
defp assign_pagination_defaults(socket) do
socket
|> assign(:page, 1)
|> assign(:per_page, @default_per_page)
|> assign(:total_pages, 0)
end
# Apply URL parameters to socket assigns
defp apply_params(socket, params) do
filters = %{
search: params["search"] || "",
category: params["category"] || "",
status: params["status"] || "",
is_system: params["is_system"] || ""
}
page = String.to_integer(params["page"] || "1")
per_page = min(String.to_integer(params["per_page"] || "#{@default_per_page}"), @max_per_page)
sort_by = validate_sort_by(params["sort_by"])
sort_dir = validate_sort_dir(params["sort_dir"])
socket
|> assign(:filters, filters)
|> assign(:page, page)
|> assign(:per_page, per_page)
|> assign(:sort_by, sort_by)
|> assign(:sort_dir, sort_dir)
end
# Load templates based on current filters and pagination
defp load_templates(socket) do
%{filters: filters, page: page, per_page: per_page} = socket.assigns
# Build filters for Templates query, adding sort order (list only — the
# count query below intentionally drops ordering).
query_filters =
build_query_filters(filters, page, per_page)
|> Map.put(:order_by, socket.assigns.sort_by)
|> Map.put(:order_direction, socket.assigns.sort_dir)
templates = Templates.list_templates(query_filters)
# Get total count for pagination
total_count = Templates.count_templates(Map.drop(query_filters, [:limit, :offset]))
total_pages = ceil(total_count / per_page)
socket
|> assign(:templates, templates)
|> assign(:total_count, total_count)
|> assign(:total_pages, total_pages)
|> assign(:loading, false)
end
# Load template statistics
defp load_stats(socket) do
stats = Templates.get_template_stats()
assign(socket, :stats, stats)
end
# Build query filters from form filters
defp build_query_filters(filters, page, per_page) do
query_filters = %{
limit: per_page,
offset: (page - 1) * per_page,
order_by: :inserted_at,
order_direction: :desc
}
# Add non-empty filters
filters
|> Enum.reduce(query_filters, fn
{:search, search}, acc when search != "" ->
Map.put(acc, :search, search)
{:category, category}, acc when category != "" ->
Map.put(acc, :category, category)
{:status, status}, acc when status != "" ->
Map.put(acc, :status, status)
{:is_system, is_system}, acc when is_system != "" ->
Map.put(acc, :is_system, is_system == "true")
_, acc ->
acc
end)
end
# Build URL parameters from current state
defp build_url_params(assigns, additional_params) do
base_params = %{
"search" => assigns.filters.search,
"category" => assigns.filters.category,
"status" => assigns.filters.status,
"is_system" => assigns.filters.is_system,
"sort_by" => to_string(assigns.sort_by),
"sort_dir" => to_string(assigns.sort_dir),
"page" => assigns.page,
"per_page" => assigns.per_page
}
Map.merge(base_params, additional_params)
|> Enum.reject(fn {_key, value} -> value == "" or is_nil(value) end)
|> Map.new()
|> URI.encode_query()
end
# Whitelist of fields that may be passed to Templates.list_templates/1
# :order_by. Anything outside this set falls back to the default inserted_at.
# These are the only DB columns the context's apply_ordering/2 supports.
# Only Name (the "Template" column) and Usage get clickable sort headers.
# Category and Status are intentionally not sortable because the context's
# apply_ordering/2 does not order by them.
@sort_fields [:name, :usage_count, :last_used_at, :inserted_at]
defp validate_sort_by(value) when is_atom(value) do
if value in @sort_fields, do: value, else: :inserted_at
end
defp validate_sort_by(value) when is_binary(value) do
case Enum.find(@sort_fields, fn field -> Atom.to_string(field) == value end) do
nil -> :inserted_at
field -> field
end
end
defp validate_sort_by(_), do: :inserted_at
defp validate_sort_dir(:asc), do: :asc
defp validate_sort_dir(:desc), do: :desc
defp validate_sort_dir("asc"), do: :asc
defp validate_sort_dir("desc"), do: :desc
defp validate_sort_dir(_), do: :desc
# Validate clone form
defp validate_clone_form(params) do
errors = %{}
# Validate name
errors =
case String.trim(params["name"] || "") do
"" ->
Map.put(errors, :name, "Name is required")
name ->
if Regex.match?(~r/^[a-z][a-z0-9_]*$/, name) do
# Check if name already exists
case Templates.get_template_by_name(name) do
nil -> errors
_ -> Map.put(errors, :name, "Name already exists")
end
else
Map.put(
errors,
:name,
"Must start with a letter and contain only lowercase letters, numbers, and underscores"
)
end
end
# Validate display_name
errors =
case String.trim(params["display_name"] || "") do
"" ->
Map.put(errors, :display_name, "Display name is required")
_ ->
errors
end
errors
end
end