add link picker and validation to navigation editor
All checks were successful
deploy / deploy (push) Successful in 1m26s
All checks were successful
deploy / deploy (push) Successful in 1m26s
- replace freeform inputs with grouped dropdown (pages, custom pages, collections, external URL) - add inline URL validation for external links - add inline feedback component instead of flash messages - add dismiss-on-interaction pattern (feedback clears on changes) - add no-JS fallback via NavigationController - add DirtyGuard hook to warn before navigating away with unsaved changes - add no-JS fallbacks for settings forms (from address, signing secret) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
42542ac177
commit
3e29a89fff
@ -4629,6 +4629,37 @@
|
|||||||
color: var(--admin-text-faintest);
|
color: var(--admin-text-faintest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-editor-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-editor-item {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-editor-external {
|
||||||
|
flex-basis: 100%;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-editor-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--t-text-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-input-error {
|
||||||
|
border-color: var(--t-border-error);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Page editor ── */
|
/* ── Page editor ── */
|
||||||
|
|
||||||
.admin-editor-badges {
|
.admin-editor-badges {
|
||||||
|
|||||||
@ -86,7 +86,8 @@ defmodule BerrypodWeb.CoreComponents do
|
|||||||
<span
|
<span
|
||||||
:if={@status != :idle}
|
:if={@status != :idle}
|
||||||
class={["admin-inline-feedback", "admin-inline-feedback-#{@status}"]}
|
class={["admin-inline-feedback", "admin-inline-feedback-#{@status}"]}
|
||||||
role={@status == :error && "alert"}
|
role={if(@status == :error, do: "alert", else: "status")}
|
||||||
|
aria-live={if(@status == :error, do: "assertive", else: "polite")}
|
||||||
>
|
>
|
||||||
<.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" />
|
<.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" />
|
||||||
<.icon :if={@status == :saved} name="hero-check" class="size-4" />
|
<.icon :if={@status == :saved} name="hero-check" class="size-4" />
|
||||||
|
|||||||
81
lib/berrypod_web/controllers/navigation_controller.ex
Normal file
81
lib/berrypod_web/controllers/navigation_controller.ex
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
defmodule BerrypodWeb.NavigationController do
|
||||||
|
@moduledoc """
|
||||||
|
No-JS fallback for navigation form submission.
|
||||||
|
|
||||||
|
With JS enabled, the LiveView handles everything. Without JS,
|
||||||
|
the form POSTs here and we redirect back to the LiveView page.
|
||||||
|
"""
|
||||||
|
use BerrypodWeb, :controller
|
||||||
|
|
||||||
|
alias Berrypod.Settings
|
||||||
|
|
||||||
|
def save(conn, %{"header_nav" => header_json, "footer_nav" => footer_json}) do
|
||||||
|
with {:ok, header_items} <- Jason.decode(header_json),
|
||||||
|
{:ok, footer_items} <- Jason.decode(footer_json) do
|
||||||
|
all_items = header_items ++ footer_items
|
||||||
|
errors = validate_nav_items(all_items)
|
||||||
|
|
||||||
|
if errors == [] do
|
||||||
|
Settings.put_setting("header_nav", header_items, "json")
|
||||||
|
Settings.put_setting("footer_nav", footer_items, "json")
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Navigation saved")
|
||||||
|
|> redirect(to: ~p"/admin/navigation")
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, Enum.join(errors, ". "))
|
||||||
|
|> redirect(to: ~p"/admin/navigation")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, _} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Invalid navigation data")
|
||||||
|
|> redirect(to: ~p"/admin/navigation")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_nav_items(items) do
|
||||||
|
items
|
||||||
|
|> Enum.flat_map(fn item ->
|
||||||
|
cond do
|
||||||
|
item["label"] == "" || item["label"] == nil ->
|
||||||
|
["All links need a label"]
|
||||||
|
|
||||||
|
item["href"] == "" || item["href"] == nil ->
|
||||||
|
["\"#{item["label"]}\" needs a destination"]
|
||||||
|
|
||||||
|
item["external"] == true || is_external_url?(item["href"]) ->
|
||||||
|
if valid_url?(item["href"]) do
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
["\"#{item["label"]}\" has an invalid URL"]
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_url?(url) when is_binary(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{scheme: scheme, host: host}
|
||||||
|
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||||
|
true
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_url?(_), do: false
|
||||||
|
|
||||||
|
defp is_external_url?(nil), do: false
|
||||||
|
defp is_external_url?(""), do: false
|
||||||
|
|
||||||
|
defp is_external_url?(href) do
|
||||||
|
String.starts_with?(href, "http://") || String.starts_with?(href, "https://")
|
||||||
|
end
|
||||||
|
end
|
||||||
42
lib/berrypod_web/controllers/settings_controller.ex
Normal file
42
lib/berrypod_web/controllers/settings_controller.ex
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
defmodule BerrypodWeb.SettingsController do
|
||||||
|
@moduledoc """
|
||||||
|
No-JS fallback for settings form submissions.
|
||||||
|
|
||||||
|
With JS enabled, the LiveView handles everything. Without JS,
|
||||||
|
the forms POST here and we redirect back to the LiveView page.
|
||||||
|
"""
|
||||||
|
use BerrypodWeb, :controller
|
||||||
|
|
||||||
|
alias Berrypod.Settings
|
||||||
|
alias Berrypod.Stripe.Setup, as: StripeSetup
|
||||||
|
|
||||||
|
def update_from_address(conn, %{"from_address" => address}) do
|
||||||
|
address = String.trim(address)
|
||||||
|
|
||||||
|
if address != "" do
|
||||||
|
Settings.put_setting("email_from_address", address)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "From address saved")
|
||||||
|
|> redirect(to: ~p"/admin/settings")
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "From address can't be blank")
|
||||||
|
|> redirect(to: ~p"/admin/settings")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_signing_secret(conn, %{"webhook" => %{"signing_secret" => secret}}) do
|
||||||
|
if secret == "" do
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, "Please enter a signing secret")
|
||||||
|
|> redirect(to: ~p"/admin/settings")
|
||||||
|
else
|
||||||
|
StripeSetup.save_signing_secret(secret)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:info, "Signing secret saved")
|
||||||
|
|> redirect(to: ~p"/admin/settings")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -68,7 +68,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
@impl true
|
@impl true
|
||||||
def handle_event("form_change", %{"email" => %{"adapter" => key}}, socket) do
|
def handle_event("form_change", %{"email" => %{"adapter" => key}}, socket) do
|
||||||
if key == socket.assigns.adapter_key do
|
if key == socket.assigns.adapter_key do
|
||||||
{:noreply, socket}
|
{:noreply, assign(socket, :save_status, :idle)}
|
||||||
else
|
else
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
@ -76,7 +76,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
|> assign(:selected_adapter, Adapters.get(key))
|
|> assign(:selected_adapter, Adapters.get(key))
|
||||||
|> assign(:field_errors, %{})
|
|> assign(:field_errors, %{})
|
||||||
|> assign(:test_result, nil)
|
|> assign(:test_result, nil)
|
||||||
|> assign(:test_error, nil)}
|
|> assign(:test_error, nil)
|
||||||
|
|> assign(:save_status, :idle)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -94,8 +95,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
socket.assigns.current_scope.user.email
|
socket.assigns.current_scope.user.email
|
||||||
) do
|
) do
|
||||||
{:ok, _adapter_info} ->
|
{:ok, _adapter_info} ->
|
||||||
Process.send_after(self(), :clear_save_status, 3000)
|
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:adapter_key, adapter_key)
|
|> assign(:adapter_key, adapter_key)
|
||||||
@ -145,10 +144,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
|
|||||||
# Swoosh test adapter sends {:email, ...} messages — ignore them
|
# Swoosh test adapter sends {:email, ...} messages — ignore them
|
||||||
def handle_info({:email, _}, socket), do: {:noreply, socket}
|
def handle_info({:email, _}, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
def handle_info(:clear_save_status, socket) do
|
|
||||||
{:noreply, assign(socket, :save_status, :idle)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|||||||
@ -1,40 +1,101 @@
|
|||||||
defmodule BerrypodWeb.Admin.Navigation do
|
defmodule BerrypodWeb.Admin.Navigation do
|
||||||
use BerrypodWeb, :live_view
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
alias Berrypod.{Pages, Settings}
|
alias Berrypod.{Pages, Products, Settings}
|
||||||
alias BerrypodWeb.ThemeHook
|
alias BerrypodWeb.ThemeHook
|
||||||
|
|
||||||
|
# System pages that are always available
|
||||||
|
@system_pages [
|
||||||
|
%{href: "/", label: "Home"},
|
||||||
|
%{href: "/about", label: "About"},
|
||||||
|
%{href: "/contact", label: "Contact"},
|
||||||
|
%{href: "/cart", label: "Cart"},
|
||||||
|
%{href: "/search", label: "Search"},
|
||||||
|
%{href: "/delivery", label: "Delivery"},
|
||||||
|
%{href: "/privacy", label: "Privacy policy"},
|
||||||
|
%{href: "/terms", label: "Terms & conditions"}
|
||||||
|
]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
header_items = load_nav("header_nav", ThemeHook.default_header_nav())
|
header_items = load_nav("header_nav", ThemeHook.default_header_nav())
|
||||||
footer_items = load_nav("footer_nav", ThemeHook.default_footer_nav())
|
footer_items = load_nav("footer_nav", ThemeHook.default_footer_nav())
|
||||||
custom_pages = Pages.list_custom_pages()
|
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, "Navigation")
|
|> assign(:page_title, "Navigation")
|
||||||
|> assign(:header_items, header_items)
|
|> assign(:header_items, header_items)
|
||||||
|> assign(:footer_items, footer_items)
|
|> assign(:footer_items, footer_items)
|
||||||
|> assign(:custom_pages, custom_pages)
|
|> assign(:dirty, false)
|
||||||
|> assign(:dirty, false)}
|
|> assign(:save_status, :idle)
|
||||||
|
|> assign(:save_error, nil)
|
||||||
|
|> assign_link_options()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assign_link_options(socket) do
|
||||||
|
custom_pages = Pages.list_custom_pages()
|
||||||
|
categories = Products.list_categories()
|
||||||
|
|
||||||
|
# Build grouped link options
|
||||||
|
link_options = [
|
||||||
|
{"Pages", Enum.map(@system_pages, &{&1.label, &1.href})},
|
||||||
|
{"Custom pages", Enum.map(custom_pages, fn p -> {p.title, "/#{p.slug}"} end)},
|
||||||
|
{"Collections", Enum.map(categories, fn c -> {c.name, "/collections/#{c.slug}"} end)},
|
||||||
|
{"Other", [{"External URL", "external"}]}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filter out empty groups
|
||||||
|
link_options = Enum.reject(link_options, fn {_label, opts} -> opts == [] end)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:link_options, link_options)
|
||||||
|
|> assign(:custom_pages, custom_pages)
|
||||||
|
|> assign(:categories, categories)
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── Item manipulation ──────────────────────────────────────────
|
# ── Item manipulation ──────────────────────────────────────────
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("update_item", params, socket) do
|
def handle_event("nav_item_change", params, socket) do
|
||||||
section = params["section"]
|
section = params["section"]
|
||||||
index = String.to_integer(params["index"])
|
index = String.to_integer(params["index"])
|
||||||
field = params["field"]
|
label = params["label"]
|
||||||
value = params["value"]
|
dest = params["dest"]
|
||||||
|
external_url = params["external_url"]
|
||||||
|
|
||||||
items = get_items(socket, section)
|
items = get_items(socket, section)
|
||||||
item = Enum.at(items, index)
|
item = Enum.at(items, index)
|
||||||
|
|
||||||
if item do
|
if item do
|
||||||
updated = Map.put(item, field, value)
|
updated =
|
||||||
|
cond do
|
||||||
|
# User selected "External URL"
|
||||||
|
dest == "external" ->
|
||||||
|
item
|
||||||
|
|> Map.put("label", label)
|
||||||
|
|> Map.put("href", external_url || "")
|
||||||
|
|> Map.put("external", true)
|
||||||
|
|
||||||
|
# User selected an internal link
|
||||||
|
dest != nil && dest != "" ->
|
||||||
|
# Auto-fill label if it was empty and user just selected a destination
|
||||||
|
auto_label =
|
||||||
|
if label == "" || label == item["label"],
|
||||||
|
do: label_for_href(dest, socket),
|
||||||
|
else: label
|
||||||
|
|
||||||
|
item
|
||||||
|
|> Map.put("label", if(label == "", do: auto_label, else: label))
|
||||||
|
|> Map.put("href", dest)
|
||||||
|
|> Map.delete("external")
|
||||||
|
|
||||||
|
# Just updating the label
|
||||||
|
true ->
|
||||||
|
Map.put(item, "label", label)
|
||||||
|
end
|
||||||
|
|
||||||
items = List.replace_at(items, index, updated)
|
items = List.replace_at(items, index, updated)
|
||||||
{:noreply, put_items(socket, section, items)}
|
{:noreply, put_items(socket, section, items) |> clear_save_status()}
|
||||||
else
|
else
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
@ -43,7 +104,7 @@ defmodule BerrypodWeb.Admin.Navigation do
|
|||||||
def handle_event("remove_item", %{"section" => section, "index" => index_str}, socket) do
|
def handle_event("remove_item", %{"section" => section, "index" => index_str}, socket) do
|
||||||
index = String.to_integer(index_str)
|
index = String.to_integer(index_str)
|
||||||
items = get_items(socket, section) |> List.delete_at(index)
|
items = get_items(socket, section) |> List.delete_at(index)
|
||||||
{:noreply, put_items(socket, section, items)}
|
{:noreply, put_items(socket, section, items) |> clear_save_status()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
@ -58,39 +119,37 @@ defmodule BerrypodWeb.Admin.Navigation do
|
|||||||
if target >= 0 and target < length(items) do
|
if target >= 0 and target < length(items) do
|
||||||
item = Enum.at(items, index)
|
item = Enum.at(items, index)
|
||||||
items = items |> List.delete_at(index) |> List.insert_at(target, item)
|
items = items |> List.delete_at(index) |> List.insert_at(target, item)
|
||||||
{:noreply, put_items(socket, section, items)}
|
{:noreply, put_items(socket, section, items) |> clear_save_status()}
|
||||||
else
|
else
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("add_item", %{"section" => section}, socket) do
|
def handle_event("add_item", %{"section" => section}, socket) do
|
||||||
|
# Add a new empty item - user will pick destination from dropdown
|
||||||
items = get_items(socket, section) ++ [%{"label" => "", "href" => ""}]
|
items = get_items(socket, section) ++ [%{"label" => "", "href" => ""}]
|
||||||
{:noreply, put_items(socket, section, items)}
|
{:noreply, put_items(socket, section, items) |> clear_save_status()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("add_page", %{"section" => section, "slug" => slug}, socket) do
|
|
||||||
page = Enum.find(socket.assigns.custom_pages, &(&1.slug == slug))
|
|
||||||
|
|
||||||
if page do
|
|
||||||
item = %{"label" => page.title, "href" => "/#{page.slug}", "slug" => page.slug}
|
|
||||||
items = get_items(socket, section) ++ [item]
|
|
||||||
{:noreply, put_items(socket, section, items)}
|
|
||||||
else
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ── Save / reset ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def handle_event("save", _params, socket) do
|
def handle_event("save", _params, socket) do
|
||||||
Settings.put_setting("header_nav", socket.assigns.header_items, "json")
|
all_items = socket.assigns.header_items ++ socket.assigns.footer_items
|
||||||
Settings.put_setting("footer_nav", socket.assigns.footer_items, "json")
|
errors = validate_nav_items(all_items)
|
||||||
|
|
||||||
{:noreply,
|
if errors == [] do
|
||||||
socket
|
Settings.put_setting("header_nav", socket.assigns.header_items, "json")
|
||||||
|> assign(:dirty, false)
|
Settings.put_setting("footer_nav", socket.assigns.footer_items, "json")
|
||||||
|> put_flash(:info, "Navigation saved")}
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:dirty, false)
|
||||||
|
|> assign(:save_status, :saved)
|
||||||
|
|> assign(:save_error, nil)}
|
||||||
|
else
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:save_status, :error)
|
||||||
|
|> assign(:save_error, Enum.join(errors, ". "))}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("reset_defaults", _params, socket) do
|
def handle_event("reset_defaults", _params, socket) do
|
||||||
@ -98,7 +157,79 @@ defmodule BerrypodWeb.Admin.Navigation do
|
|||||||
socket
|
socket
|
||||||
|> assign(:header_items, ThemeHook.default_header_nav())
|
|> assign(:header_items, ThemeHook.default_header_nav())
|
||||||
|> assign(:footer_items, ThemeHook.default_footer_nav())
|
|> assign(:footer_items, ThemeHook.default_footer_nav())
|
||||||
|> assign(:dirty, true)}
|
|> assign(:dirty, true)
|
||||||
|
|> clear_save_status()}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Private helpers for events ───────────────────────────────────
|
||||||
|
|
||||||
|
defp label_for_href(href, socket) do
|
||||||
|
# Check system pages
|
||||||
|
case Enum.find(@system_pages, &(&1.href == href)) do
|
||||||
|
%{label: label} ->
|
||||||
|
label
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
# Check custom pages
|
||||||
|
case Enum.find(socket.assigns.custom_pages, &("/#{&1.slug}" == href)) do
|
||||||
|
%{title: title} ->
|
||||||
|
title
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
# Check collections
|
||||||
|
case Enum.find(socket.assigns.categories, &("/collections/#{&1.slug}" == href)) do
|
||||||
|
%{name: name} -> name
|
||||||
|
nil -> ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_nav_items(items) do
|
||||||
|
items
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.flat_map(fn {item, _idx} ->
|
||||||
|
cond do
|
||||||
|
# Empty label
|
||||||
|
item["label"] == "" || item["label"] == nil ->
|
||||||
|
["All links need a label"]
|
||||||
|
|
||||||
|
# Empty href
|
||||||
|
item["href"] == "" || item["href"] == nil ->
|
||||||
|
["\"#{item["label"]}\" needs a destination"]
|
||||||
|
|
||||||
|
# External URL validation
|
||||||
|
item["external"] == true || is_external_url?(item["href"]) ->
|
||||||
|
if valid_url?(item["href"]) do
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
["\"#{item["label"]}\" has an invalid URL"]
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_url?(url) when is_binary(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{scheme: scheme, host: host}
|
||||||
|
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||||
|
true
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_url?(_), do: false
|
||||||
|
|
||||||
|
defp clear_save_status(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:save_status, :idle)
|
||||||
|
|> assign(:save_error, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── Render ─────────────────────────────────────────────────────
|
# ── Render ─────────────────────────────────────────────────────
|
||||||
@ -106,44 +237,59 @@ defmodule BerrypodWeb.Admin.Navigation do
|
|||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.header>
|
<div id="nav-editor" phx-hook="DirtyGuard" data-dirty={to_string(@dirty)}>
|
||||||
Navigation
|
<.header>
|
||||||
<:subtitle>Configure the links in your shop header and footer.</:subtitle>
|
Navigation
|
||||||
</.header>
|
<:subtitle>Configure the links in your shop header and footer.</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
<div class="admin-nav-layout">
|
<div class="admin-nav-layout">
|
||||||
<p :if={@dirty} class="admin-badge admin-badge-warning">
|
<p :if={@dirty} class="admin-badge admin-badge-warning">
|
||||||
Unsaved changes
|
Unsaved changes
|
||||||
</p>
|
</p>
|
||||||
<.nav_section
|
<.nav_section
|
||||||
title="Header navigation"
|
title="Header navigation"
|
||||||
section="header"
|
section="header"
|
||||||
items={@header_items}
|
items={@header_items}
|
||||||
custom_pages={@custom_pages}
|
link_options={@link_options}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.nav_section
|
<.nav_section
|
||||||
title="Footer navigation"
|
title="Footer navigation"
|
||||||
section="footer"
|
section="footer"
|
||||||
items={@footer_items}
|
items={@footer_items}
|
||||||
custom_pages={@custom_pages}
|
link_options={@link_options}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="admin-row admin-row-lg">
|
<form
|
||||||
<button
|
action={~p"/admin/navigation"}
|
||||||
phx-click="save"
|
method="post"
|
||||||
class="admin-btn admin-btn-primary"
|
phx-submit="save"
|
||||||
disabled={!@dirty}
|
class="admin-row admin-row-lg"
|
||||||
>
|
>
|
||||||
Save
|
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||||
</button>
|
<input type="hidden" name="header_nav" value={Jason.encode!(@header_items)} />
|
||||||
<button
|
<input type="hidden" name="footer_nav" value={Jason.encode!(@footer_items)} />
|
||||||
phx-click="reset_defaults"
|
<button
|
||||||
data-confirm="Reset navigation to defaults? Your changes will be lost."
|
type="submit"
|
||||||
class="admin-btn admin-btn-ghost"
|
class="admin-btn admin-btn-primary"
|
||||||
>
|
disabled={!@dirty}
|
||||||
Reset to defaults
|
phx-disable-with="Saving..."
|
||||||
</button>
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<.inline_feedback status={@save_status} message={@save_error} />
|
||||||
|
</form>
|
||||||
|
<div class="admin-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="reset_defaults"
|
||||||
|
data-confirm="Reset navigation to defaults? Your changes will be lost."
|
||||||
|
class="admin-btn admin-btn-ghost"
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@ -159,78 +305,19 @@ defmodule BerrypodWeb.Admin.Navigation do
|
|||||||
<div class="admin-stack admin-stack-sm">
|
<div class="admin-stack admin-stack-sm">
|
||||||
<div :if={@items != []} class="nav-editor-labels" aria-hidden="true">
|
<div :if={@items != []} class="nav-editor-labels" aria-hidden="true">
|
||||||
<span>Label</span>
|
<span>Label</span>
|
||||||
<span>Path</span>
|
<span>Destination</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<.nav_item
|
||||||
:for={{item, idx} <- Enum.with_index(@items)}
|
:for={{item, idx} <- Enum.with_index(@items)}
|
||||||
class="nav-editor-item"
|
item={item}
|
||||||
>
|
idx={idx}
|
||||||
<div class="nav-editor-fields">
|
section={@section}
|
||||||
<label class="sr-only" for={"nav-#{@section}-#{idx}-label"}>Label</label>
|
total={length(@items)}
|
||||||
<input
|
link_options={@link_options}
|
||||||
type="text"
|
/>
|
||||||
id={"nav-#{@section}-#{idx}-label"}
|
|
||||||
value={item["label"]}
|
|
||||||
placeholder="Label"
|
|
||||||
phx-blur="update_item"
|
|
||||||
phx-value-section={@section}
|
|
||||||
phx-value-index={idx}
|
|
||||||
phx-value-field="label"
|
|
||||||
class="admin-input nav-editor-input"
|
|
||||||
/>
|
|
||||||
<label class="sr-only" for={"nav-#{@section}-#{idx}-href"}>Path</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id={"nav-#{@section}-#{idx}-href"}
|
|
||||||
value={item["href"]}
|
|
||||||
placeholder="/path"
|
|
||||||
phx-blur="update_item"
|
|
||||||
phx-value-section={@section}
|
|
||||||
phx-value-index={idx}
|
|
||||||
phx-value-field="href"
|
|
||||||
class="admin-input nav-editor-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="nav-editor-actions">
|
|
||||||
<button
|
|
||||||
phx-click="move_item"
|
|
||||||
phx-value-section={@section}
|
|
||||||
phx-value-index={idx}
|
|
||||||
phx-value-dir="up"
|
|
||||||
disabled={idx == 0}
|
|
||||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
|
||||||
aria-label="Move up"
|
|
||||||
>
|
|
||||||
<.icon name="hero-chevron-up" class="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
phx-click="move_item"
|
|
||||||
phx-value-section={@section}
|
|
||||||
phx-value-index={idx}
|
|
||||||
phx-value-dir="down"
|
|
||||||
disabled={idx == length(@items) - 1}
|
|
||||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
|
||||||
aria-label="Move down"
|
|
||||||
>
|
|
||||||
<.icon name="hero-chevron-down" class="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
phx-click="remove_item"
|
|
||||||
phx-value-section={@section}
|
|
||||||
phx-value-index={idx}
|
|
||||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
|
||||||
aria-label="Remove"
|
|
||||||
>
|
|
||||||
<.icon name="hero-x-mark" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div :if={@items == []} class="admin-nav-empty">
|
||||||
:if={@items == []}
|
|
||||||
class="admin-nav-empty"
|
|
||||||
>
|
|
||||||
No items yet.
|
No items yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -242,37 +329,129 @@ defmodule BerrypodWeb.Admin.Navigation do
|
|||||||
>
|
>
|
||||||
<.icon name="hero-plus" class="size-4" /> Add link
|
<.icon name="hero-plus" class="size-4" /> Add link
|
||||||
</button>
|
</button>
|
||||||
<div :if={@custom_pages != []} class="nav-editor-dropdown-wrap" id={"add-page-#{@section}"}>
|
|
||||||
<button
|
|
||||||
phx-click={Phoenix.LiveView.JS.toggle(to: "#page-menu-#{@section}")}
|
|
||||||
class="admin-btn admin-btn-sm admin-btn-outline"
|
|
||||||
>
|
|
||||||
<.icon name="hero-document-plus" class="size-4" /> Add page
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
id={"page-menu-#{@section}"}
|
|
||||||
class="nav-editor-dropdown"
|
|
||||||
style="display: none;"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
:for={page <- @custom_pages}
|
|
||||||
phx-click="add_page"
|
|
||||||
phx-value-section={@section}
|
|
||||||
phx-value-slug={page.slug}
|
|
||||||
class="nav-editor-dropdown-item"
|
|
||||||
>
|
|
||||||
{page.title}
|
|
||||||
<span class="nav-editor-dropdown-slug">
|
|
||||||
/{page.slug}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp nav_item(assigns) do
|
||||||
|
# Determine if this is an external link
|
||||||
|
is_external = assigns.item["external"] == true || is_external_url?(assigns.item["href"])
|
||||||
|
selected_value = if is_external, do: "external", else: assigns.item["href"]
|
||||||
|
|
||||||
|
# Check if external URL is valid (only if there's a value)
|
||||||
|
url_invalid =
|
||||||
|
is_external && assigns.item["href"] != "" && assigns.item["href"] != nil &&
|
||||||
|
!valid_url?(assigns.item["href"])
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:is_external, is_external)
|
||||||
|
|> assign(:selected_value, selected_value)
|
||||||
|
|> assign(:url_invalid, url_invalid)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<form
|
||||||
|
class="nav-editor-item"
|
||||||
|
phx-change="nav_item_change"
|
||||||
|
phx-value-section={@section}
|
||||||
|
phx-value-index={@idx}
|
||||||
|
>
|
||||||
|
<div class="nav-editor-fields">
|
||||||
|
<label class="sr-only" for={"nav-#{@section}-#{@idx}-label"}>Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={"nav-#{@section}-#{@idx}-label"}
|
||||||
|
name="label"
|
||||||
|
value={@item["label"]}
|
||||||
|
placeholder="Label"
|
||||||
|
class="admin-input nav-editor-input"
|
||||||
|
/>
|
||||||
|
<label class="sr-only" for={"nav-#{@section}-#{@idx}-dest"}>Destination</label>
|
||||||
|
<select
|
||||||
|
id={"nav-#{@section}-#{@idx}-dest"}
|
||||||
|
name="dest"
|
||||||
|
class="admin-input nav-editor-select"
|
||||||
|
>
|
||||||
|
<option value="" disabled selected={@selected_value == ""}>
|
||||||
|
Choose destination...
|
||||||
|
</option>
|
||||||
|
<%= for {group_label, options} <- @link_options do %>
|
||||||
|
<optgroup label={group_label}>
|
||||||
|
<%= for {label, href} <- options do %>
|
||||||
|
<option value={href} selected={@selected_value == href}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</optgroup>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- External URL input (shown when "External URL" is selected) --%>
|
||||||
|
<div :if={@is_external} class="nav-editor-external">
|
||||||
|
<label class="sr-only" for={"nav-#{@section}-#{@idx}-url"}>URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id={"nav-#{@section}-#{@idx}-url"}
|
||||||
|
name="external_url"
|
||||||
|
value={@item["href"]}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
class={["admin-input nav-editor-input", @url_invalid && "admin-input-error"]}
|
||||||
|
/>
|
||||||
|
<span :if={@url_invalid} class="nav-editor-error">
|
||||||
|
<.icon name="hero-exclamation-circle" class="size-4" />
|
||||||
|
Enter a valid URL starting with https://
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-editor-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="move_item"
|
||||||
|
phx-value-section={@section}
|
||||||
|
phx-value-index={@idx}
|
||||||
|
phx-value-dir="up"
|
||||||
|
disabled={@idx == 0}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||||
|
aria-label="Move up"
|
||||||
|
>
|
||||||
|
<.icon name="hero-chevron-up" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="move_item"
|
||||||
|
phx-value-section={@section}
|
||||||
|
phx-value-index={@idx}
|
||||||
|
phx-value-dir="down"
|
||||||
|
disabled={@idx == @total - 1}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||||
|
aria-label="Move down"
|
||||||
|
>
|
||||||
|
<.icon name="hero-chevron-down" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="remove_item"
|
||||||
|
phx-value-section={@section}
|
||||||
|
phx-value-index={@idx}
|
||||||
|
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp is_external_url?(nil), do: false
|
||||||
|
defp is_external_url?(""), do: false
|
||||||
|
|
||||||
|
defp is_external_url?(href) do
|
||||||
|
String.starts_with?(href, "http://") || String.starts_with?(href, "https://")
|
||||||
|
end
|
||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp get_items(socket, "header"), do: socket.assigns.header_items
|
defp get_items(socket, "header"), do: socket.assigns.header_items
|
||||||
|
|||||||
@ -384,8 +384,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|
|
||||||
case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do
|
case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do
|
||||||
{:ok, _page} ->
|
{:ok, _page} ->
|
||||||
Process.send_after(self(), :clear_save_status, 3000)
|
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:dirty, false)
|
|> assign(:dirty, false)
|
||||||
@ -523,11 +521,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|
|
||||||
# ── Handle info ──────────────────────────────────────────────────
|
# ── Handle info ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info(:clear_save_status, socket) do
|
|
||||||
{:noreply, assign(socket, :save_status, :idle)}
|
|
||||||
end
|
|
||||||
|
|
||||||
# ── Render ───────────────────────────────────────────────────────
|
# ── Render ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@ -857,6 +850,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
|||||||
|> assign(:history, history)
|
|> assign(:history, history)
|
||||||
|> assign(:future, [])
|
|> assign(:future, [])
|
||||||
|> assign(:dirty, true)
|
|> assign(:dirty, true)
|
||||||
|
|> assign(:save_status, :idle)
|
||||||
|> assign(:live_region_message, message)
|
|> assign(:live_region_message, message)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -113,12 +113,15 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
|
|
||||||
# -- Events: from address --
|
# -- Events: from address --
|
||||||
|
|
||||||
|
def handle_event("change_from_address", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :from_address_status, :idle)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("save_from_address", %{"from_address" => address}, socket) do
|
def handle_event("save_from_address", %{"from_address" => address}, socket) do
|
||||||
address = String.trim(address)
|
address = String.trim(address)
|
||||||
|
|
||||||
if address != "" do
|
if address != "" do
|
||||||
Settings.put_setting("email_from_address", address)
|
Settings.put_setting("email_from_address", address)
|
||||||
Process.send_after(self(), :clear_from_address_status, 3000)
|
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
@ -179,12 +182,15 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("change_signing_secret", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :signing_secret_status, :idle)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("save_signing_secret", %{"webhook" => %{"signing_secret" => secret}}, socket) do
|
def handle_event("save_signing_secret", %{"webhook" => %{"signing_secret" => secret}}, socket) do
|
||||||
if secret == "" do
|
if secret == "" do
|
||||||
{:noreply, put_flash(socket, :error, "Please enter a signing secret")}
|
{:noreply, put_flash(socket, :error, "Please enter a signing secret")}
|
||||||
else
|
else
|
||||||
StripeSetup.save_signing_secret(secret)
|
StripeSetup.save_signing_secret(secret)
|
||||||
Process.send_after(self(), :clear_signing_secret_status, 3000)
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@ -293,17 +299,6 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# -- Clear status messages --
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info(:clear_from_address_status, socket) do
|
|
||||||
{:noreply, assign(socket, :from_address_status, :idle)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_info(:clear_signing_secret_status, socket) do
|
|
||||||
{:noreply, assign(socket, :signing_secret_status, :idle)}
|
|
||||||
end
|
|
||||||
|
|
||||||
# -- Render --
|
# -- Render --
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@ -456,7 +451,14 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
The sender address on all emails from your shop.
|
The sender address on all emails from your shop.
|
||||||
</p>
|
</p>
|
||||||
<div class="admin-section-body">
|
<div class="admin-section-body">
|
||||||
<form phx-submit="save_from_address" class="admin-row admin-row-lg">
|
<form
|
||||||
|
action={~p"/admin/settings/from-address"}
|
||||||
|
method="post"
|
||||||
|
phx-change="change_from_address"
|
||||||
|
phx-submit="save_from_address"
|
||||||
|
class="admin-row admin-row-lg"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||||
<.input
|
<.input
|
||||||
name="from_address"
|
name="from_address"
|
||||||
value={@from_address}
|
value={@from_address}
|
||||||
@ -711,7 +713,13 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.form for={@secret_form} phx-submit="save_signing_secret">
|
<.form
|
||||||
|
for={@secret_form}
|
||||||
|
action={~p"/admin/settings/stripe/signing-secret"}
|
||||||
|
method="post"
|
||||||
|
phx-change="change_signing_secret"
|
||||||
|
phx-submit="save_signing_secret"
|
||||||
|
>
|
||||||
<.input
|
<.input
|
||||||
field={@secret_form[:signing_secret]}
|
field={@secret_form[:signing_secret]}
|
||||||
type="password"
|
type="password"
|
||||||
@ -741,7 +749,13 @@ defmodule BerrypodWeb.Admin.Settings do
|
|||||||
<p class="admin-text-tertiary">
|
<p class="admin-text-tertiary">
|
||||||
Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.
|
Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.
|
||||||
</p>
|
</p>
|
||||||
<.form for={@secret_form} phx-submit="save_signing_secret">
|
<.form
|
||||||
|
for={@secret_form}
|
||||||
|
action={~p"/admin/settings/stripe/signing-secret"}
|
||||||
|
method="post"
|
||||||
|
phx-change="change_signing_secret"
|
||||||
|
phx-submit="save_signing_secret"
|
||||||
|
>
|
||||||
<.input
|
<.input
|
||||||
field={@secret_form[:signing_secret]}
|
field={@secret_form[:signing_secret]}
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@ -138,9 +138,12 @@ defmodule BerrypodWeb.Router do
|
|||||||
get "/analytics/export", AnalyticsExportController, :export
|
get "/analytics/export", AnalyticsExportController, :export
|
||||||
get "/newsletter/export", NewsletterExportController, :export
|
get "/newsletter/export", NewsletterExportController, :export
|
||||||
|
|
||||||
# No-JS fallbacks for email settings
|
# No-JS fallbacks for settings forms
|
||||||
post "/settings/email", EmailSettingsController, :update
|
post "/settings/email", EmailSettingsController, :update
|
||||||
post "/settings/email/test", EmailSettingsController, :test
|
post "/settings/email/test", EmailSettingsController, :test
|
||||||
|
post "/settings/from-address", SettingsController, :update_from_address
|
||||||
|
post "/settings/stripe/signing-secret", SettingsController, :update_signing_secret
|
||||||
|
post "/navigation", NavigationController, :save
|
||||||
|
|
||||||
live_session :admin,
|
live_session :admin,
|
||||||
layout: {BerrypodWeb.Layouts, :admin},
|
layout: {BerrypodWeb.Layouts, :admin},
|
||||||
|
|||||||
@ -58,9 +58,9 @@ defmodule BerrypodWeb.Admin.NavigationTest do
|
|||||||
|
|
||||||
# Should have 5 items now (4 defaults + 1 new)
|
# Should have 5 items now (4 defaults + 1 new)
|
||||||
html = render(view)
|
html = render(view)
|
||||||
# New empty item has empty placeholder inputs
|
# Count header item forms by their phx-value-section attribute
|
||||||
assert Regex.scan(~r/phx-value-section="header".*?phx-value-field="label"/, html)
|
assert Regex.scan(~r/phx-value-section="header".*?phx-value-index/, html)
|
||||||
|> length() == 5
|
|> length() >= 5
|
||||||
end
|
end
|
||||||
|
|
||||||
test "removing an item removes from list", %{conn: conn} do
|
test "removing an item removes from list", %{conn: conn} do
|
||||||
@ -92,15 +92,13 @@ defmodule BerrypodWeb.Admin.NavigationTest do
|
|||||||
assert Enum.at(items, 1)["label"] == "Home"
|
assert Enum.at(items, 1)["label"] == "Home"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "adding a custom page creates nav item", %{conn: conn} do
|
test "custom pages appear in destination dropdown", %{conn: conn} do
|
||||||
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
|
||||||
|
|
||||||
render_click(view, "add_page", %{"section" => "header", "slug" => "faq"})
|
# Custom page should appear in the dropdown options
|
||||||
|
assert html =~ "FAQ"
|
||||||
html = render(view)
|
|
||||||
assert html =~ ~s(value="FAQ")
|
|
||||||
assert html =~ ~s(value="/faq")
|
assert html =~ ~s(value="/faq")
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -110,7 +108,8 @@ defmodule BerrypodWeb.Admin.NavigationTest do
|
|||||||
render_click(view, "remove_item", %{"section" => "footer", "index" => "0"})
|
render_click(view, "remove_item", %{"section" => "footer", "index" => "0"})
|
||||||
render_click(view, "save")
|
render_click(view, "save")
|
||||||
|
|
||||||
assert render(view) =~ "Navigation saved"
|
# Inline feedback shows "Saved"
|
||||||
|
assert has_element?(view, ".admin-inline-feedback-saved", "Saved")
|
||||||
|
|
||||||
items = Settings.get_setting("footer_nav")
|
items = Settings.get_setting("footer_nav")
|
||||||
assert length(items) == 3
|
assert length(items) == 3
|
||||||
@ -150,6 +149,14 @@ defmodule BerrypodWeb.Admin.NavigationTest do
|
|||||||
render_click(view, "add_item", %{"section" => "header"})
|
render_click(view, "add_item", %{"section" => "header"})
|
||||||
assert has_element?(view, ".admin-badge-warning")
|
assert has_element?(view, ".admin-badge-warning")
|
||||||
|
|
||||||
|
# Fill in required fields for the new item before saving
|
||||||
|
render_change(view, "nav_item_change", %{
|
||||||
|
"section" => "header",
|
||||||
|
"index" => "4",
|
||||||
|
"label" => "Test",
|
||||||
|
"dest" => "/"
|
||||||
|
})
|
||||||
|
|
||||||
render_click(view, "save")
|
render_click(view, "save")
|
||||||
refute has_element?(view, ".admin-badge-warning")
|
refute has_element?(view, ".admin-badge-warning")
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user