Packages

phoenix_kit

1.7.39
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
phoenix_kit lib modules publishing web editor forms.ex
Raw

lib/modules/publishing/web/editor/forms.ex

defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do
@moduledoc """
Form building and management for the publishing editor.
Handles form construction, normalization, slug tracking,
and form state management.
"""
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.Metadata
alias PhoenixKit.Modules.Publishing.Storage
alias PhoenixKit.Utils.Slug
# ============================================================================
# Form Building
# ============================================================================
@doc """
Builds a form map from a post.
"""
def post_form(post) do
base_form(post)
|> add_slug_field_if_needed(post)
|> normalize_form()
end
@doc """
Build form for a post, handling new translations appropriately.
For new translations (no file on disk yet), inherits status from the primary language.
For existing files, uses the file's own status to avoid confusion between
what the dropdown shows and what the language switcher shows.
"""
def post_form_with_primary_status(blog_slug, post, version) do
form = post_form(post)
primary_language = post[:primary_language] || Storage.get_primary_language()
original_language = post[:original_language] || post.language
is_new_translation = Map.get(post, :is_new_translation, false)
# If primary language OR existing translation (not new), use own status
if original_language == primary_language or not is_new_translation do
form
else
# For NEW translations only, inherit status from primary language as a default
# Use appropriate identifier based on post mode
post_identifier = get_post_identifier(post)
case Publishing.read_post(blog_slug, post_identifier, primary_language, version) do
{:ok, primary_post} ->
primary_status = Map.get(primary_post.metadata, :status, "draft")
Map.put(form, "status", primary_status)
{:error, _} ->
form
end
end
end
# Get the correct post identifier based on mode
# For timestamp mode: extract date/time from path (e.g., "2025-12-31/03:42")
# For slug mode: use the post slug
defp get_post_identifier(post) do
case Map.get(post, :mode) do
:timestamp -> extract_timestamp_identifier(post.path)
_ -> post.slug
end
end
# Extract timestamp identifier (date/time) from a timestamp mode path
defp extract_timestamp_identifier(path) when is_binary(path) do
case Regex.run(~r/(\d{4}-\d{2}-\d{2}\/\d{2}:\d{2})/, path) do
[_, timestamp] -> timestamp
nil -> path
end
end
defp extract_timestamp_identifier(path), do: path
defp base_form(post) do
%{
"status" => post.metadata.status || "draft",
"published_at" => get_published_at(post),
"featured_image_id" => Map.get(post.metadata, :featured_image_id, ""),
"url_slug" => get_url_slug_for_form(post)
}
end
defp get_published_at(post) do
post.metadata.published_at ||
DateTime.utc_now()
|> floor_datetime_to_minute()
|> DateTime.to_iso8601()
end
defp get_url_slug_for_form(post) do
url_slug_from_metadata = Map.get(post.metadata, :url_slug)
url_slug_from_post = Map.get(post, :url_slug)
cond do
url_slug_from_metadata not in [nil, ""] ->
url_slug_from_metadata
url_slug_from_post not in [nil, ""] and url_slug_from_post != post.slug ->
url_slug_from_post
true ->
""
end
end
defp add_slug_field_if_needed(form, post) do
case get_post_mode(post) do
:slug -> Map.put(form, "slug", get_post_slug(post))
_ -> form
end
end
defp get_post_mode(post) do
Map.get(post, :mode) || Map.get(post, "mode")
end
defp get_post_slug(post) do
post.slug || post["slug"] || Map.get(post.metadata, :slug) || ""
end
# ============================================================================
# Form Normalization
# ============================================================================
@doc """
Normalizes a form map to ensure consistent values.
"""
def normalize_form(form) when is_map(form) do
featured_image_id =
form
|> Map.get("featured_image_id", "")
|> to_string()
|> String.trim()
# Normalize url_slug: trim and downcase, empty string if nil
url_slug =
form
|> Map.get("url_slug", "")
|> to_string()
|> String.trim()
|> String.downcase()
base =
%{
"status" => Map.get(form, "status", "draft") || "draft",
"published_at" => normalize_published_at(Map.get(form, "published_at")),
"featured_image_id" => featured_image_id,
"url_slug" => url_slug
}
case Map.fetch(form, "slug") do
{:ok, slug} ->
# Normalize slug: trim and downcase to match validation requirements
normalized_slug = slug |> to_string() |> String.trim() |> String.downcase()
Map.put(base, "slug", normalized_slug)
:error ->
base
end
end
def normalize_form(_),
do: %{
"status" => "draft",
"published_at" => "",
"slug" => "",
"featured_image_id" => "",
"url_slug" => ""
}
defp normalize_published_at(nil), do: ""
defp normalize_published_at(value) when is_binary(value) do
trimmed = String.trim(value)
cond do
trimmed == "" ->
""
String.length(trimmed) == 16 and String.contains?(trimmed, "T") ->
trimmed <> ":00Z"
true ->
case DateTime.from_iso8601(trimmed) do
{:ok, dt, _} ->
dt
|> floor_datetime_to_minute()
|> DateTime.to_iso8601()
_ ->
trimmed
end
end
end
defp normalize_published_at(_), do: ""
# ============================================================================
# Form Tracking
# ============================================================================
@doc """
Assigns form with tracking for slug auto-generation.
"""
def assign_form_with_tracking(socket, form, opts \\ []) do
slug = Map.get(form, "slug", "")
url_slug = Map.get(form, "url_slug", "")
post_slug = Map.get(socket.assigns.post || %{}, :slug, "")
# Track slug (for primary language)
slug_manually_set =
case Keyword.fetch(opts, :slug_manually_set) do
{:ok, value} -> value
:error -> Map.get(socket.assigns, :slug_manually_set, false)
end
last_auto_slug =
case Keyword.fetch(opts, :last_auto_slug) do
{:ok, value} -> value
:error -> slug
end
# Track url_slug (for translations)
url_slug_manually_set =
case Keyword.fetch(opts, :url_slug_manually_set) do
{:ok, value} ->
value
:error ->
existing = Map.get(socket.assigns, :url_slug_manually_set, false)
if existing do
true
else
url_slug != "" and url_slug != post_slug
end
end
last_auto_url_slug =
case Keyword.fetch(opts, :last_auto_url_slug) do
{:ok, value} -> value
:error -> Map.get(socket.assigns, :last_auto_url_slug, "")
end
socket
|> Phoenix.Component.assign(:form, form)
|> Phoenix.Component.assign(:last_auto_slug, last_auto_slug)
|> Phoenix.Component.assign(:slug_manually_set, slug_manually_set)
|> Phoenix.Component.assign(:last_auto_url_slug, last_auto_url_slug)
|> Phoenix.Component.assign(:url_slug_manually_set, url_slug_manually_set)
end
# ============================================================================
# Slug Auto-Generation
# ============================================================================
@doc """
Updates slug from content if applicable.
Returns {socket, new_form, slug_events}.
"""
def maybe_update_slug_from_content(socket, content, opts \\ []) do
content = content || ""
cond do
not slug_update_applicable?(socket, content) ->
no_slug_update(socket)
Map.get(socket.assigns, :is_primary_language, true) ->
maybe_update_primary_slug(socket, content, opts)
true ->
maybe_update_translation_url_slug(socket, content, opts)
end
end
defp slug_update_applicable?(socket, content) do
socket.assigns.blog_mode == "slug" and String.trim(content) != ""
end
defp maybe_update_primary_slug(socket, content, opts) do
force? = Keyword.get(opts, :force, false)
slug_manually_set? = Map.get(socket.assigns, :slug_manually_set, false)
if not force? and slug_manually_set? do
no_slug_update(socket)
else
update_slug_from_content(socket, content)
end
end
defp maybe_update_translation_url_slug(socket, content, opts) do
force? = Keyword.get(opts, :force, false)
url_slug_manually_set? = Map.get(socket.assigns, :url_slug_manually_set, false)
if not force? and url_slug_manually_set? do
no_slug_update(socket)
else
update_url_slug_from_content(socket, content)
end
end
defp no_slug_update(socket), do: {socket, socket.assigns.form, []}
defp update_slug_from_content(socket, content) do
title = Metadata.extract_title_from_content(content)
current_slug = socket.assigns.post.slug || Map.get(socket.assigns.form, "slug", "")
case Storage.generate_unique_slug(socket.assigns.blog_slug, title, nil,
current_slug: current_slug
) do
{:ok, ""} ->
no_slug_update(socket)
{:ok, new_slug} ->
apply_new_slug(socket, new_slug)
{:error, _reason} ->
no_slug_update(socket)
end
end
defp update_url_slug_from_content(socket, content) do
title = Metadata.extract_title_from_content(content)
current_url_slug = Map.get(socket.assigns.form, "url_slug", "")
# Generate a slug from the title
new_url_slug = Slug.slugify(title)
if new_url_slug == "" do
no_slug_update(socket)
else
apply_new_url_slug(socket, new_url_slug, current_url_slug)
end
end
defp apply_new_slug(socket, new_slug) do
current_slug = Map.get(socket.assigns.form, "slug", "")
if new_slug != current_slug do
form =
socket.assigns.form
|> Map.put("slug", new_slug)
|> normalize_form()
socket =
socket
|> Phoenix.Component.assign(:last_auto_slug, new_slug)
|> Phoenix.Component.assign(:slug_manually_set, false)
{socket, form, [{"update-slug", %{slug: new_slug}}]}
else
socket =
socket
|> Phoenix.Component.assign(:last_auto_slug, new_slug)
|> Phoenix.Component.assign(:slug_manually_set, false)
{socket, socket.assigns.form, []}
end
end
defp apply_new_url_slug(socket, new_url_slug, current_url_slug) do
if new_url_slug != current_url_slug do
form =
socket.assigns.form
|> Map.put("url_slug", new_url_slug)
|> normalize_form()
socket =
socket
|> Phoenix.Component.assign(:last_auto_url_slug, new_url_slug)
|> Phoenix.Component.assign(:url_slug_manually_set, false)
{socket, form, [{"update-url-slug", %{url_slug: new_url_slug}}]}
else
socket =
socket
|> Phoenix.Component.assign(:last_auto_url_slug, new_url_slug)
|> Phoenix.Component.assign(:url_slug_manually_set, false)
{socket, socket.assigns.form, []}
end
end
@doc """
Preserve auto-generated url_slug when browser sends empty value.
"""
def preserve_auto_url_slug(params, socket) do
browser_url_slug = Map.get(params, "url_slug", "")
last_auto = Map.get(socket.assigns, :last_auto_url_slug, "")
manually_set = Map.get(socket.assigns, :url_slug_manually_set, false)
if browser_url_slug == "" and last_auto != "" and not manually_set do
Map.put(params, "url_slug", last_auto)
else
params
end
end
@doc """
Push slug events to the client.
"""
def push_slug_events(socket, events) do
Enum.reduce(events, socket, fn {event, data}, acc ->
Phoenix.LiveView.push_event(acc, event, data)
end)
end
# ============================================================================
# Change Detection
# ============================================================================
@doc """
Checks if the form has changes compared to the original post.
"""
def dirty?(post, form, content) do
normalized_form = normalize_form(form)
normalized_form != post_form(post) || content != post.content
end
# ============================================================================
# Helpers
# ============================================================================
@doc """
Floors a DateTime to the minute (sets seconds and microseconds to 0).
"""
def floor_datetime_to_minute(%DateTime{} = datetime) do
%DateTime{datetime | second: 0, microsecond: {0, 0}}
end
@doc """
Converts a published_at value to datetime-local input format.
"""
def datetime_local_value(nil), do: ""
def datetime_local_value(value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _} ->
dt
|> floor_datetime_to_minute()
|> DateTime.to_naive()
|> NaiveDateTime.to_iso8601()
_ ->
value
end
end
@doc """
Updates form with selected media file.
"""
def update_form_with_media(form, file_id) do
Map.put(form, "featured_image_id", file_id)
end
end