From fb5bb227952708dbabe91cb47cfe45e9b17f4978 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 23 Nov 2025 18:16:30 +0200 Subject: [PATCH 1/7] Autosave featured image edits, add inline save button, and keep JS warning hidden --- .../live/modules/blogging/editor.ex | 65 +++++++++++-------- .../live/modules/blogging/editor.html.heex | 19 ++++++ 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.ex b/lib/phoenix_kit_web/live/modules/blogging/editor.ex index 2f51059b6..991786a62 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.ex @@ -261,12 +261,15 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do # Clear the featured image from the form (form is a simple map, not a struct) updated_form = Map.put(socket.assigns.form, "featured_image_id", "") - {:noreply, - socket - |> assign(:form, updated_form) - |> assign(:has_pending_changes, true) - |> put_flash(:info, gettext("Featured image cleared")) - |> push_event("changes-status", %{has_changes: true})} + socket = + socket + |> assign(:form, updated_form) + |> assign(:has_pending_changes, true) + |> put_flash(:info, gettext("Featured image cleared")) + |> push_event("changes-status", %{has_changes: true}) + |> schedule_autosave() + + {:noreply, socket} end def handle_event("generate_slug_from_content", _params, socket) do @@ -485,30 +488,38 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do file_id = List.first(file_ids) inserting_image_component = Map.get(socket.assigns, :inserting_image_component, false) - socket = - if file_id && inserting_image_component do - # Insert image component at cursor via JavaScript - js_code = "window.insertImageComponent && window.insertImageComponent('#{file_id}')" + {socket, autosave?} = + cond do + file_id && inserting_image_component -> + # Insert image component at cursor via JavaScript + js_code = "window.insertImageComponent && window.insertImageComponent('#{file_id}')" - socket - |> assign(:show_media_selector, false) - |> assign(:inserting_image_component, false) - |> put_flash(:info, gettext("Image component inserted")) - |> push_event("exec-js", %{js: js_code}) - else - if file_id do - socket - |> assign(:form, update_form_with_media(socket.assigns.form, file_id)) - |> assign(:has_pending_changes, true) - |> assign(:show_media_selector, false) - |> put_flash(:info, gettext("Featured image selected")) - |> push_event("changes-status", %{has_changes: true}) - else - socket - |> assign(:show_media_selector, false) - end + { + socket + |> assign(:show_media_selector, false) + |> assign(:inserting_image_component, false) + |> put_flash(:info, gettext("Image component inserted")) + |> push_event("exec-js", %{js: js_code}), + false + } + + file_id -> + { + socket + |> assign(:form, update_form_with_media(socket.assigns.form, file_id)) + |> assign(:has_pending_changes, true) + |> assign(:show_media_selector, false) + |> put_flash(:info, gettext("Featured image selected")) + |> push_event("changes-status", %{has_changes: true}), + true + } + + true -> + {socket |> assign(:show_media_selector, false), false} end + socket = if autosave?, do: schedule_autosave(socket), else: socket + {:noreply, socket} end diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex index 0f44f055e..9d0085b63 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex @@ -11,6 +11,7 @@ <% nonce = assigns[:script_csp_nonce] || assigns[:csp_nonce] || "" %> From 4175afb4cd7025d4e93d5bfdcd92aa63c861f3d6 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 23 Nov 2025 21:04:31 +0200 Subject: [PATCH 2/7] Align blogging and entity slug UX with autosave expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add PhoenixKit.Utils.Slug and reuse it from the blogging storage layer so slug generation/uniqueness is centralized and honors the current post’s slug * keep track of last auto-generated slug vs manual edits in the blogging editor, keep generating slugs after autosaves, and push_patch to the new edit path when a slug/path change occurs * teach entity data forms to share the slug helper, enforce the slug pattern, and de-duplicate against existing records (excluding the current one) * overhaul entity field modal state: normalize field maps, auto-generate keys from labels until the slug input is touched, and throttle all inputs with phx-debounce --- lib/phoenix_kit/utils/slug.ex | 61 ++++++ .../live/modules/blogging/context/storage.ex | 66 ++---- .../live/modules/blogging/editor.ex | 191 +++++++++++++----- .../live/modules/entities/data_form.ex | 38 ++-- .../live/modules/entities/data_form.html.heex | 2 + .../live/modules/entities/entity_form.ex | 130 +++++++----- .../modules/entities/entity_form.html.heex | 25 ++- 7 files changed, 346 insertions(+), 167 deletions(-) create mode 100644 lib/phoenix_kit/utils/slug.ex diff --git a/lib/phoenix_kit/utils/slug.ex b/lib/phoenix_kit/utils/slug.ex new file mode 100644 index 000000000..0e507d28e --- /dev/null +++ b/lib/phoenix_kit/utils/slug.ex @@ -0,0 +1,61 @@ +defmodule PhoenixKit.Utils.Slug do + @moduledoc """ + Helpers for generating consistent, URL-friendly slugs across PhoenixKit. + + Provides functions to slugify arbitrary text, enforce separator rules, + and ensure uniqueness by delegating existence checks via callback. + """ + + @doc """ + Converts the given `text` into a slug. + + Options: + * `:separator` - character used between words (defaults to "-") + + Returns an empty string when the input is blank or cannot be converted. + """ + @spec slugify(String.t() | nil, keyword()) :: String.t() + def slugify(text, opts \\ []) + + def slugify(nil, _opts), do: "" + + def slugify(text, opts) when is_binary(text) do + separator = Keyword.get(opts, :separator, "-") + escaped_separator = Regex.escape(separator) + + text + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/u, separator) + |> String.replace(~r/#{escaped_separator}+/, separator) + |> String.trim(separator) + end + + def slugify(_text, _opts), do: "" + + @doc """ + Ensures the provided slug is unique by calling `exists_fun`. + + `exists_fun` should return truthy when the slug is already taken. + """ + @spec ensure_unique(String.t(), (String.t() -> boolean())) :: String.t() + def ensure_unique("", _exists_fun), do: "" + + def ensure_unique(slug, exists_fun) when is_function(exists_fun, 1) do + if exists_fun.(slug) do + increment_slug(slug, 2, exists_fun) + else + slug + end + end + + defp increment_slug(base_slug, counter, exists_fun) do + candidate = "#{base_slug}-#{counter}" + + if exists_fun.(candidate) do + increment_slug(base_slug, counter + 1, exists_fun) + else + candidate + end + end +end + diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex b/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex index d72f746ae..3f778a03d 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex @@ -13,6 +13,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do alias PhoenixKit.Modules.Languages alias PhoenixKit.Settings + alias PhoenixKit.Utils.Slug alias PhoenixKitWeb.Live.Modules.Blogging.Metadata @doc """ @@ -117,6 +118,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do |> File.dir?() end + defp slug_exists_for_generation?(_blog_slug, candidate, current_slug) + when not is_nil(current_slug) and candidate == current_slug, + do: false + + defp slug_exists_for_generation?(blog_slug, candidate, _current_slug) do + slug_exists?(blog_slug, candidate) + end + @doc """ Generates a unique slug based on title and optional preferred slug. @@ -124,19 +133,21 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do - `:invalid_format` - slug has invalid format - `:reserved_language_code` - slug is a reserved language code """ - @spec generate_unique_slug(String.t(), String.t(), String.t() | nil) :: + @spec generate_unique_slug(String.t(), String.t(), String.t() | nil, keyword()) :: {:ok, String.t()} | {:error, :invalid_format | :reserved_language_code} - def generate_unique_slug(blog_slug, title, preferred_slug \\ nil) do + def generate_unique_slug(blog_slug, title, preferred_slug \\ nil, opts \\ []) do + current_slug = Keyword.get(opts, :current_slug) + base_slug_result = case preferred_slug do nil -> - {:ok, generate_slug_from_title(title)} + {:ok, Slug.slugify(title)} slug when is_binary(slug) -> - sanitized = sanitize_slug(slug) + sanitized = Slug.slugify(slug) if sanitized == "" do - {:ok, generate_slug_from_title(title)} + {:ok, Slug.slugify(title)} else # Validate the sanitized slug case validate_slug(sanitized) do @@ -152,7 +163,10 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do case base_slug_result do {:ok, base_slug} when base_slug != "" -> - {:ok, ensure_unique_slug(blog_slug, base_slug)} + {:ok, + Slug.ensure_unique(base_slug, fn candidate -> + slug_exists_for_generation?(blog_slug, candidate, current_slug) + end)} {:ok, ""} -> # Empty slug - return empty for auto-generation, will show placeholder @@ -163,45 +177,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do end end - defp generate_slug_from_title(title) when is_binary(title) do - title - |> String.downcase() - |> String.replace(~r/[^a-z0-9]+/u, "-") - |> String.replace(~r/-+/, "-") - |> String.trim("-") - |> case do - # Return empty string instead of generating fallback - # This allows the form to show placeholder and user can enter custom slug - "" -> "" - slug -> slug - end - end - - defp sanitize_slug(slug) do - slug - |> String.downcase() - |> String.replace(~r/[^a-z0-9]+/u, "-") - |> String.replace(~r/-+/, "-") - |> String.trim("-") - end - - defp ensure_unique_slug(blog_slug, base_slug) do - if slug_exists?(blog_slug, base_slug) do - find_available_slug(blog_slug, base_slug, 2) - else - base_slug - end - end - - defp find_available_slug(blog_slug, base_slug, counter) do - candidate = "#{base_slug}-#{counter}" - - if slug_exists?(blog_slug, candidate) do - find_available_slug(blog_slug, base_slug, counter + 1) - else - candidate - end - end + # slug helpers now handled by PhoenixKit.Utils.Slug @type post :: %{ blog: String.t() | nil, diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.ex b/lib/phoenix_kit_web/live/modules/blogging/editor.ex index 991786a62..71b13903b 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.ex @@ -32,6 +32,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:media_selected_ids, MapSet.new()) |> assign(:is_autosaving, false) |> assign(:autosave_timer, nil) + |> assign(:slug_manually_set, false) + |> assign(:last_auto_slug, "") {:ok, socket} end @@ -65,12 +67,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do now = DateTime.utc_now() |> DateTime.truncate(:second) |> floor_datetime_to_minute() virtual_post = build_virtual_post(blog_slug, blog_mode, primary_language, now) + form = post_form(virtual_post) + socket = socket |> assign(:blog_mode, blog_mode) |> assign(:post, virtual_post) |> assign(:blog_name, Blogging.blog_name(blog_slug) || blog_slug) - |> assign(:form, post_form(virtual_post)) + |> assign_form_with_tracking(form, slug_manually_set: false) |> assign(:content, "") |> assign(:current_language, primary_language) |> assign(:available_languages, virtual_post.available_languages) @@ -116,11 +120,13 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> Map.put(:mode, post.mode) |> Map.put(:slug, post.slug) + form = post_form(virtual_post) + socket |> assign(:blog_mode, blog_mode) |> assign(:post, virtual_post) |> assign(:blog_name, Blogging.blog_name(blog_slug) || blog_slug) - |> assign(:form, post_form(virtual_post)) + |> assign_form_with_tracking(form, slug_manually_set: false) |> assign(:content, "") |> assign(:current_language, switch_to_lang) |> assign(:available_languages, post.available_languages) @@ -138,11 +144,13 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:public_url, nil) |> push_event("changes-status", %{has_changes: false}) else + form = post_form(post) + socket |> assign(:blog_mode, blog_mode) |> assign(:post, %{post | blog: blog_slug}) |> assign(:blog_name, Blogging.blog_name(blog_slug) || blog_slug) - |> assign(:form, post_form(post)) + |> assign_form_with_tracking(form) |> assign(:content, post.content) |> assign(:current_language, post.language) |> assign(:available_languages, post.available_languages) @@ -187,6 +195,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> Map.merge(params) |> normalize_form() + slug_manually_set = + if Map.has_key?(params, "slug") do + slug_value = Map.get(new_form, "slug", "") + slug_value != "" && slug_value != socket.assigns.last_auto_slug + else + socket.assigns.slug_manually_set + end + has_changes = dirty?(socket.assigns.post, new_form, socket.assigns.content) # Update public_url if status changed @@ -201,6 +217,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do socket = socket |> assign(:form, new_form) + |> assign(:slug_manually_set, slug_manually_set) |> assign(:has_pending_changes, has_changes) |> assign(:public_url, public_url) |> clear_flash() @@ -275,22 +292,9 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do def handle_event("generate_slug_from_content", _params, socket) do # Only generate slug if in slug mode and slug is empty if socket.assigns.blog_mode == "slug" do - current_slug = Map.get(socket.assigns.form, "slug", "") content = socket.assigns.content || "" - - new_slug = - if current_slug == "" and String.trim(content) != "" do - # Extract title from content and use it for slug - title = Metadata.extract_title_from_content(content) - Storage.generate_unique_slug(socket.assigns.blog_slug, title, nil) - else - current_slug - end - - new_form = - socket.assigns.form - |> Map.put("slug", new_slug) - |> normalize_form() + {socket, new_form, slug_events} = + maybe_update_slug_from_content(socket, content, force: true) has_changes = dirty?(socket.assigns.post, new_form, socket.assigns.content) @@ -299,40 +303,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:form, new_form) |> assign(:has_pending_changes, has_changes) |> push_event("changes-status", %{has_changes: has_changes}) - |> push_event("update-slug", %{slug: new_slug})} + |> push_slug_events(slug_events)} else {:noreply, socket} end end def handle_event("update_content", %{"content" => content}, socket) do - # Auto-generate slug from content live for new unsaved posts only - is_new_unsaved = - Map.get(socket.assigns, :is_new_post, false) || - Map.get(socket.assigns, :is_new_translation, false) - - {new_form, slug_events} = - if socket.assigns.blog_mode == "slug" && is_new_unsaved && String.trim(content) != "" do - title = Metadata.extract_title_from_content(content) - - case Storage.generate_unique_slug(socket.assigns.blog_slug, title, nil) do - {:ok, new_slug} -> - current_slug = Map.get(socket.assigns.form, "slug", "") - - if new_slug != current_slug do - {Map.put(socket.assigns.form, "slug", new_slug), - [{"update-slug", %{slug: new_slug}}]} - else - {socket.assigns.form, []} - end - - {:error, _} -> - # If slug generation fails, keep current form - {socket.assigns.form, []} - end - else - {socket.assigns.form, []} - end + {socket, new_form, slug_events} = maybe_update_slug_from_content(socket, content) has_changes = dirty?(socket.assigns.post, new_form, content) @@ -344,9 +322,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> push_event("changes-status", %{has_changes: has_changes}) socket = - Enum.reduce(slug_events, socket, fn {event, data}, acc -> - push_event(acc, event, data) - end) + push_slug_events(socket, slug_events) # Trigger debounced autosave if changes detected socket = @@ -451,7 +427,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do {:noreply, socket |> assign(:post, virtual_post) - |> assign(:form, post_form(virtual_post)) + |> assign_form_with_tracking(post_form(virtual_post), slug_manually_set: false) |> assign(:content, "") |> assign(:current_language, new_language) |> assign(:has_pending_changes, false) @@ -636,6 +612,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do defp update_existing_post(socket, params) do scope = socket.assigns[:phoenix_kit_current_scope] + old_path = socket.assigns.post.path + case Blogging.update_post(socket.assigns.blog_slug, socket.assigns.post, params, %{ scope: scope }) do @@ -648,13 +626,16 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do do: nil, else: gettext("Post saved") + form = post_form(post) + socket = socket |> assign(:post, post) - |> assign(:form, post_form(post)) + |> assign_form_with_tracking(form) |> assign(:content, post.content) |> assign(:has_pending_changes, false) |> push_event("changes-status", %{has_changes: false}) + |> maybe_update_current_path(old_path, post.path) {:noreply, if(flash_message, do: put_flash(socket, :info, flash_message), else: socket)} @@ -708,10 +689,12 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do do: nil, else: success_message + form = post_form(updated_post) + socket = socket |> assign(:post, updated_post) - |> assign(:form, post_form(updated_post)) + |> assign_form_with_tracking(form) |> assign(:content, updated_post.content) |> assign(:available_languages, updated_post.available_languages) |> assign(:has_pending_changes, false) @@ -1079,7 +1062,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:blog_mode, mode_to_string(mode)) |> assign(:blog_slug, blog_slug) |> assign(:post, post) - |> assign(:form, form) + |> assign_form_with_tracking(form, slug_manually_set: false) |> assign(:content, data[:content] || "") |> assign(:current_language, language) |> assign(:available_languages, post.available_languages) @@ -1320,4 +1303,106 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do # The form is a simple map with string keys Map.put(form, "featured_image_id", file_id) end + + defp assign_form_with_tracking(socket, form, opts \\ []) do + slug = Map.get(form, "slug", "") + + 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 + + socket + |> assign(:form, form) + |> assign(:last_auto_slug, last_auto_slug) + |> assign(:slug_manually_set, slug_manually_set) + end + + defp maybe_update_slug_from_content(socket, content, opts \\ []) do + force? = Keyword.get(opts, :force, false) + content = content || "" + + cond do + socket.assigns.blog_mode != "slug" -> + {socket, socket.assigns.form, []} + + not force? && Map.get(socket.assigns, :slug_manually_set, false) -> + {socket, socket.assigns.form, []} + + String.trim(content) == "" -> + {socket, socket.assigns.form, []} + + true -> + 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, ""} -> + {socket, socket.assigns.form, []} + + {:ok, new_slug} -> + 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 + |> assign(:last_auto_slug, new_slug) + |> assign(:slug_manually_set, false) + + {socket, form, [{"update-slug", %{slug: new_slug}}]} + else + socket = + socket + |> assign(:last_auto_slug, new_slug) + |> assign(:slug_manually_set, false) + + {socket, socket.assigns.form, []} + end + + {:error, _reason} -> + {socket, socket.assigns.form, []} + end + end + end + + defp push_slug_events(socket, events) do + Enum.reduce(events, socket, fn {event, data}, acc -> + push_event(acc, event, data) + end) + end + + defp maybe_update_current_path(socket, old_path, new_path) + when new_path in [nil, ""] or new_path == old_path, + do: socket + + defp maybe_update_current_path(socket, _old_path, new_path) do + encoded = URI.encode(new_path) + + path = + Routes.path( + "/admin/blogging/#{socket.assigns.blog_slug}/edit?path=#{encoded}", + locale: socket.assigns.current_locale + ) + + socket + |> assign(:current_path, path) + |> push_patch(to: path) + end end diff --git a/lib/phoenix_kit_web/live/modules/entities/data_form.ex b/lib/phoenix_kit_web/live/modules/entities/data_form.ex index 8fe3ad6a3..20a330600 100644 --- a/lib/phoenix_kit_web/live/modules/entities/data_form.ex +++ b/lib/phoenix_kit_web/live/modules/entities/data_form.ex @@ -14,6 +14,7 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do alias PhoenixKit.Entities.Presence alias PhoenixKit.Entities.PresenceHelpers alias PhoenixKit.Settings + alias PhoenixKit.Utils.Slug alias PhoenixKit.Utils.Routes @impl true @@ -201,6 +202,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do @impl true def handle_event("validate", %{"phoenix_kit_entity_data" => data_params}, socket) do if socket.assigns[:lock_owner?] do + entity_id = socket.assigns.entity.id + record_id = socket.assigns.data_record.id form_data = Map.get(data_params, "data", %{}) data_params = @@ -211,16 +214,16 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do end data_params = - if is_nil(socket.assigns.data_record.id) do + if is_nil(record_id) do title = data_params["title"] || "" current_slug = data_params["slug"] || "" current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset) previous_title = current_data.title || "" - auto_generated_slug = generate_slug_from_title(previous_title) + auto_generated_slug = auto_generate_entity_slug(entity_id, record_id, previous_title) if current_slug == "" || current_slug == auto_generated_slug do - Map.put(data_params, "slug", generate_slug_from_title(title)) + Map.put(data_params, "slug", auto_generate_entity_slug(entity_id, record_id, title)) else data_params end @@ -370,6 +373,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do def handle_event("generate_slug", _params, socket) do if socket.assigns[:lock_owner?] do changeset = socket.assigns.changeset + entity_id = socket.assigns.entity.id + record_id = socket.assigns.data_record.id # Get title from changeset (includes both changes and original data) title = Ecto.Changeset.get_field(changeset, :title) || "" @@ -378,8 +383,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do if title == "" do {:noreply, socket} else - # Generate slug from title - slug = generate_slug_from_title(title) + # Generate slug from title using shared utility + slug = auto_generate_entity_slug(entity_id, record_id, title) # Get ALL current field values from the changeset # This includes both changed values and original struct values @@ -698,16 +703,25 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do defp normalize_record_key(key) when is_binary(key), do: key defp normalize_record_key(key), do: to_string(key) - defp generate_slug_from_title(title) when is_binary(title) do + defp auto_generate_entity_slug(_entity_id, _record_id, title) when title in [nil, ""], do: "" + + defp auto_generate_entity_slug(entity_id, current_record_id, title) do title - |> String.downcase() - |> String.replace(~r/[^\w\s-]/, "") - |> String.replace(~r/\s+/, "-") - |> String.replace(~r/-+/, "-") - |> String.trim("-") + |> Slug.slugify() + |> Slug.ensure_unique(&slug_taken_by_other?(entity_id, &1, current_record_id)) end - defp generate_slug_from_title(_), do: "" + defp slug_taken_by_other?(_entity_id, "", _current_record_id), do: false + + defp slug_taken_by_other?(entity_id, candidate, current_record_id) do + case EntityData.get_by_slug(entity_id, candidate) do + nil -> + false + + %EntityData{id: id} -> + is_nil(current_record_id) || id != current_record_id + end + end defp populate_presence_info(socket, type, id) do # Get all presences sorted by joined_at (FIFO order) diff --git a/lib/phoenix_kit_web/live/modules/entities/data_form.html.heex b/lib/phoenix_kit_web/live/modules/entities/data_form.html.heex index 73582fe16..774280527 100644 --- a/lib/phoenix_kit_web/live/modules/entities/data_form.html.heex +++ b/lib/phoenix_kit_web/live/modules/entities/data_form.html.heex @@ -109,6 +109,8 @@ value={Ecto.Changeset.get_field(@changeset, :slug) || ""} placeholder={gettext("auto-generated-slug")} class="input input-bordered w-full" + pattern="[a-z0-9]+(?:-[a-z0-9]+)*" + title={gettext("Use lowercase letters, numbers, and hyphens only.")} phx-debounce="300" disabled={@readonly?} /> diff --git a/lib/phoenix_kit_web/live/modules/entities/entity_form.ex b/lib/phoenix_kit_web/live/modules/entities/entity_form.ex index c6d0edd84..f948ac7e5 100644 --- a/lib/phoenix_kit_web/live/modules/entities/entity_form.ex +++ b/lib/phoenix_kit_web/live/modules/entities/entity_form.ex @@ -13,6 +13,7 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do alias PhoenixKit.Entities.Presence alias PhoenixKit.Entities.PresenceHelpers alias PhoenixKit.Settings + alias PhoenixKit.Utils.Slug alias PhoenixKit.Utils.HeroIcons alias PhoenixKit.Utils.Routes @@ -70,8 +71,9 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do |> assign(:field_types, FieldTypes.for_picker()) |> assign(:show_field_form, false) |> assign(:editing_field_index, nil) - |> assign(:field_form, %{}) + |> assign(:field_form, new_field_form()) |> assign(:field_error, nil) + |> assign(:field_key_manually_set, false) |> assign(:show_icon_picker, false) |> assign(:icon_search, "") |> assign(:selected_category, "All") @@ -222,7 +224,6 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do changeset = socket.assigns.entity |> Entities.change_entity(entity_params) - |> Map.put(:action, :validate) socket = assign(socket, :changeset, changeset) @@ -289,7 +290,7 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do |> assign(:fields, fields) |> assign(:show_field_form, false) |> assign(:editing_field_index, nil) - |> assign(:field_form, %{}) + |> assign(:field_form, new_field_form()) |> assign(:field_error, nil) |> assign(:show_icon_picker, false) |> assign(:delete_confirm_index, nil) @@ -424,14 +425,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do socket |> assign(:show_field_form, true) |> assign(:editing_field_index, nil) - |> assign(:field_form, %{ - "type" => "text", - "key" => "", - "label" => "", - "required" => false, - "default" => "", - "options" => [] - }) + |> assign(:field_form, new_field_form()) + |> assign(:field_key_manually_set, false) |> assign(:field_error, nil) |> assign(:delete_confirm_index, nil) @@ -450,7 +445,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do socket |> assign(:show_field_form, true) |> assign(:editing_field_index, index) - |> assign(:field_form, field || %{}) + |> assign(:field_form, normalize_field_form(field) || %{}) + |> assign(:field_key_manually_set, true) |> assign(:field_error, nil) reply_with_broadcast(socket) @@ -464,7 +460,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do socket |> assign(:show_field_form, false) |> assign(:editing_field_index, nil) - |> assign(:field_form, %{}) + |> assign(:field_form, new_field_form()) + |> assign(:field_key_manually_set, false) |> assign(:field_error, nil) reply_with_broadcast(socket) @@ -554,38 +551,27 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do end end - def handle_event("update_field_form", %{"field" => field_params}, socket) do + def handle_event("update_field_form", %{"field" => field_params} = params, socket) do if socket.assigns[:lock_owner?] do + target = Map.get(params, "_target", []) + manual_key? = manual_key_target?(target) + field_params = + if manual_key?, do: field_params, else: Map.delete(field_params, "key") + # Update field form with live changes - current_form = socket.assigns.field_form - updated_form = Map.merge(current_form, field_params) + current_form = normalize_field_form(socket.assigns.field_form) - # Auto-generate key from label when adding new field (not editing) updated_form = - if is_nil(socket.assigns.editing_field_index) do - # Only auto-generate if label changed and key wasn't manually edited - label = updated_form["label"] || "" - current_key = updated_form["key"] || "" - - # Check if the current key was auto-generated from the previous label - previous_label = current_form["label"] || "" - auto_generated_key = generate_slug_from_name(previous_label) - - # If key matches the auto-generated one or is empty, update it - if current_key == "" || current_key == auto_generated_key do - Map.put(updated_form, "key", generate_slug_from_name(label)) - else - # User manually edited the key, don't overwrite it - updated_form - end - else - # In edit mode, don't auto-generate - updated_form - end + current_form + |> Map.merge(field_params) + |> maybe_auto_update_field_key(current_form, socket.assigns.field_key_manually_set, + editing?: socket.assigns.editing_field_index != nil + ) socket = socket |> assign(:field_form, updated_form) + |> assign(:field_key_manually_set, manual_key? || socket.assigns.field_key_manually_set) # Clear error when user makes changes |> assign(:field_error, nil) @@ -914,7 +900,7 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do |> assign(:fields, fields) |> assign(:show_field_form, false) |> assign(:editing_field_index, nil) - |> assign(:field_form, %{}) + |> assign(:field_form, new_field_form()) |> assign(:field_error, nil) end @@ -1040,18 +1026,68 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do "entities-form-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false)) end - defp generate_slug_from_name(name) when is_binary(name) do - name - |> String.downcase() - |> String.replace(~r/[^\w\s-]/, "") - |> String.replace(~r/\s+/, "_") - |> String.replace(~r/-+/, "_") - |> String.replace(~r/_+/, "_") - |> String.trim("_") - end + defp generate_slug_from_name(name) when is_binary(name), + do: Slug.slugify(name, separator: "_") defp generate_slug_from_name(_), do: "" + defp new_field_form do + %{ + "type" => "text", + "key" => "", + "label" => "", + "required" => false, + "default" => "", + "options" => [] + } + end + + defp normalize_field_form(nil), do: new_field_form() + + defp normalize_field_form(field) when is_map(field) do + Enum.reduce(field, %{}, fn {key, value}, acc -> + cond do + is_binary(key) -> Map.put(acc, key, value) + is_atom(key) -> Map.put(acc, Atom.to_string(key), value) + true -> acc + end + end) + end + + defp maybe_auto_update_field_key(updated_form, previous_form, manual?, opts) do + if manual? || Keyword.get(opts, :editing?, false) do + updated_form + else + auto_update_field_key(updated_form, previous_form) + end + end + + defp auto_update_field_key(updated_form, previous_form) do + label = fetch_form_value(updated_form, "label") || "" + current_key = fetch_form_value(updated_form, "key") || "" + previous_label = fetch_form_value(previous_form, "label") || "" + auto_generated_key = generate_slug_from_name(previous_label) + + if label != "" && (current_key == "" || current_key == auto_generated_key) do + Map.put(updated_form, "key", generate_slug_from_name(label)) + else + updated_form + end + end + + defp fetch_form_value(form, key) do + Map.get(form, key) || + case key do + "label" -> Map.get(form, :label) + "key" -> Map.get(form, :key) + "type" -> Map.get(form, :type) + _ -> nil + end + end + + defp manual_key_target?(["field", "key"]), do: true + defp manual_key_target?(_), do: false + defp populate_presence_info(socket, type, id) do # Get all presences sorted by joined_at (FIFO order) presences = PresenceHelpers.get_sorted_presences(type, id) diff --git a/lib/phoenix_kit_web/live/modules/entities/entity_form.html.heex b/lib/phoenix_kit_web/live/modules/entities/entity_form.html.heex index 733df52fb..5e0601d47 100644 --- a/lib/phoenix_kit_web/live/modules/entities/entity_form.html.heex +++ b/lib/phoenix_kit_web/live/modules/entities/entity_form.html.heex @@ -425,6 +425,7 @@ +
+ <.label>{gettext("Field Label")} * + <.label class="label"> {gettext("Display name for users")} @@ -474,6 +476,7 @@ class="input input-bordered w-full" placeholder={gettext("field_name")} value={@field_form["key"]} + phx-debounce="300" required /> <.label class="label"> @@ -503,6 +506,7 @@ type="text" name="field[default]" class="input input-bordered w-full" + phx-debounce="300" value={@field_form["default"]} />
@@ -529,6 +533,7 @@ type="text" value={option} class="input input-bordered flex-1" + phx-debounce="blur" phx-blur="update_option" phx-value-index={option_index} placeholder={gettext("Option value")} From 94021df61028c385e30730c467879e2263b87f70 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 23 Nov 2025 21:17:58 +0200 Subject: [PATCH 3/7] Fix JavaScript warning banner persistence in blogging editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed an issue where the JavaScript warning banner would reappear after LiveView updates (e.g., when opening/closing the media selector modal). Changes: - Wrap warning banner in parent div with phx-update="ignore" - Prevents LiveView from re-rendering the banner on updates - Banner hides once on initial load and stays hidden across all updates - Preserves intended behavior: banner only shows when JS is disabled The previous implementation without phx-update would re-render the banner on every LiveView patch, but the hide script wouldn't re-execute, causing the banner to become visible again. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../live/modules/blogging/editor.html.heex | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex index 9d0085b63..fc78dcf9e 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex @@ -9,22 +9,24 @@ current_locale={@current_locale} > <% nonce = assigns[:script_csp_nonce] || assigns[:csp_nonce] || "" %> -