add nav editors to Site tab with live preview
All checks were successful
deploy / deploy (push) Successful in 3m27s
All checks were successful
deploy / deploy (push) Successful in 3m27s
- Add header and footer nav editors to Site tab with drag-to-reorder, add/remove items, and destination picker (pages, collections, external) - Live preview updates as you edit nav items - Remove legacy /admin/navigation page and controller (was saving to Settings table, now uses nav_items table) - Update error_html.ex and pages/editor.ex to load nav from nav_items table - Update link_scanner to read from nav_items table, edit path now /?edit=site - Add Site.default_header_nav/0 and default_footer_nav/0 for previews/errors - Remove fallback logic from theme_hook.ex (database is now source of truth) - Seed default nav items and social links during setup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,17 +137,21 @@ defmodule Berrypod.Redirects.LinkScannerTest do
|
||||
|
||||
describe "scan_nav/0" do
|
||||
test "extracts URLs from header and footer nav" do
|
||||
Berrypod.Settings.put_setting(
|
||||
"header_nav",
|
||||
[%{"label" => "Shop", "href" => "/collections/all"}],
|
||||
"json"
|
||||
)
|
||||
{:ok, _} =
|
||||
Berrypod.Site.create_nav_item(%{
|
||||
location: "header",
|
||||
label: "Shop",
|
||||
url: "/collections/all",
|
||||
position: 0
|
||||
})
|
||||
|
||||
Berrypod.Settings.put_setting(
|
||||
"footer_nav",
|
||||
[%{"label" => "Privacy", "href" => "/privacy"}],
|
||||
"json"
|
||||
)
|
||||
{:ok, _} =
|
||||
Berrypod.Site.create_nav_item(%{
|
||||
location: "footer",
|
||||
label: "Privacy",
|
||||
url: "/privacy",
|
||||
position: 0
|
||||
})
|
||||
|
||||
links = LinkScanner.scan_nav()
|
||||
urls = Enum.map(links, & &1.url)
|
||||
@@ -185,11 +189,13 @@ defmodule Berrypod.Redirects.LinkScannerTest do
|
||||
end
|
||||
|
||||
test "finds nav items using a URL" do
|
||||
Berrypod.Settings.put_setting(
|
||||
"header_nav",
|
||||
[%{"label" => "Shop", "href" => "/test-nav-link"}],
|
||||
"json"
|
||||
)
|
||||
{:ok, _} =
|
||||
Berrypod.Site.create_nav_item(%{
|
||||
location: "header",
|
||||
label: "Shop",
|
||||
url: "/test-nav-link",
|
||||
position: 0
|
||||
})
|
||||
|
||||
sources = LinkScanner.find_sources("/test-nav-link")
|
||||
|
||||
@@ -197,7 +203,7 @@ defmodule Berrypod.Redirects.LinkScannerTest do
|
||||
[source] = sources
|
||||
assert source.type == "nav_item"
|
||||
assert source.label =~ "Header nav"
|
||||
assert source.edit_path == "/admin/navigation"
|
||||
assert source.edit_path == "/?edit=site"
|
||||
end
|
||||
|
||||
test "returns empty for unused URL" do
|
||||
|
||||
@@ -6,16 +6,30 @@ defmodule Berrypod.SiteTest do
|
||||
|
||||
describe "social links CRUD" do
|
||||
test "creates a social link" do
|
||||
assert {:ok, link} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com/test"})
|
||||
assert {:ok, link} =
|
||||
Site.create_social_link(%{
|
||||
platform: "instagram",
|
||||
url: "https://instagram.com/test"
|
||||
})
|
||||
|
||||
assert link.platform == "instagram"
|
||||
assert link.url == "https://instagram.com/test"
|
||||
assert link.position == 0
|
||||
end
|
||||
|
||||
test "lists social links ordered by position" do
|
||||
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
|
||||
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
|
||||
{:ok, _} = Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
|
||||
{:ok, _} =
|
||||
Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
|
||||
|
||||
{:ok, _} =
|
||||
Site.create_social_link(%{
|
||||
platform: "instagram",
|
||||
url: "https://instagram.com",
|
||||
position: 0
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
|
||||
|
||||
links = Site.list_social_links()
|
||||
assert length(links) == 3
|
||||
@@ -24,22 +38,35 @@ defmodule Berrypod.SiteTest do
|
||||
|
||||
test "updates a social link" do
|
||||
{:ok, link} = Site.create_social_link(%{platform: "custom", url: ""})
|
||||
{:ok, updated} = Site.update_social_link(link, %{url: "https://example.com", platform: "website"})
|
||||
|
||||
{:ok, updated} =
|
||||
Site.update_social_link(link, %{url: "https://example.com", platform: "website"})
|
||||
|
||||
assert updated.url == "https://example.com"
|
||||
assert updated.platform == "website"
|
||||
end
|
||||
|
||||
test "deletes a social link" do
|
||||
{:ok, link} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com"})
|
||||
{:ok, link} =
|
||||
Site.create_social_link(%{platform: "instagram", url: "https://instagram.com"})
|
||||
|
||||
assert {:ok, _} = Site.delete_social_link(link)
|
||||
assert Site.list_social_links() == []
|
||||
end
|
||||
|
||||
test "reorders social links" do
|
||||
{:ok, a} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
|
||||
{:ok, b} = Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
|
||||
{:ok, c} = Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
|
||||
{:ok, a} =
|
||||
Site.create_social_link(%{
|
||||
platform: "instagram",
|
||||
url: "https://instagram.com",
|
||||
position: 0
|
||||
})
|
||||
|
||||
{:ok, b} =
|
||||
Site.create_social_link(%{platform: "twitter", url: "https://twitter.com", position: 1})
|
||||
|
||||
{:ok, c} =
|
||||
Site.create_social_link(%{platform: "github", url: "https://github.com", position: 2})
|
||||
|
||||
# Reorder: github, instagram, twitter
|
||||
Site.reorder_social_links([c.id, a.id, b.id])
|
||||
@@ -51,7 +78,12 @@ defmodule Berrypod.SiteTest do
|
||||
|
||||
describe "social_links_for_shop/0" do
|
||||
test "returns links formatted for shop components" do
|
||||
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com/test", position: 0})
|
||||
{:ok, _} =
|
||||
Site.create_social_link(%{
|
||||
platform: "instagram",
|
||||
url: "https://instagram.com/test",
|
||||
position: 0
|
||||
})
|
||||
|
||||
[link] = Site.social_links_for_shop()
|
||||
assert link.platform == :instagram
|
||||
@@ -60,7 +92,13 @@ defmodule Berrypod.SiteTest do
|
||||
end
|
||||
|
||||
test "filters out links with empty URLs" do
|
||||
{:ok, _} = Site.create_social_link(%{platform: "instagram", url: "https://instagram.com", position: 0})
|
||||
{:ok, _} =
|
||||
Site.create_social_link(%{
|
||||
platform: "instagram",
|
||||
url: "https://instagram.com",
|
||||
position: 0
|
||||
})
|
||||
|
||||
{:ok, _} = Site.create_social_link(%{platform: "custom", url: "", position: 1})
|
||||
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: nil, position: 2})
|
||||
|
||||
@@ -89,7 +127,9 @@ defmodule Berrypod.SiteTest do
|
||||
end
|
||||
|
||||
test "preserves app deep links" do
|
||||
assert SocialLink.normalize_url("tg://resolve?domain=channel") == "tg://resolve?domain=channel"
|
||||
assert SocialLink.normalize_url("tg://resolve?domain=channel") ==
|
||||
"tg://resolve?domain=channel"
|
||||
|
||||
assert SocialLink.normalize_url("spotify:track:123") == "spotify:track:123"
|
||||
assert SocialLink.normalize_url("rss://feed.example.com") == "rss://feed.example.com"
|
||||
end
|
||||
@@ -177,10 +217,14 @@ defmodule Berrypod.SiteTest do
|
||||
end
|
||||
|
||||
test "accepts URLs with any scheme" do
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "telegram", url: "tg://resolve"})
|
||||
changeset =
|
||||
SocialLink.changeset(%SocialLink{}, %{platform: "telegram", url: "tg://resolve"})
|
||||
|
||||
assert changeset.valid?
|
||||
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "rss", url: "rss://feed.example.com"})
|
||||
changeset =
|
||||
SocialLink.changeset(%SocialLink{}, %{platform: "rss", url: "rss://feed.example.com"})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
defmodule BerrypodWeb.Admin.NavigationTest do
|
||||
use BerrypodWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Berrypod.AccountsFixtures
|
||||
|
||||
alias Berrypod.{Pages, Settings}
|
||||
alias Berrypod.Pages.PageCache
|
||||
|
||||
setup do
|
||||
PageCache.invalidate_all()
|
||||
user = user_fixture()
|
||||
%{user: user}
|
||||
end
|
||||
|
||||
describe "unauthenticated" do
|
||||
test "redirects to login", %{conn: conn} do
|
||||
{:error, redirect} = live(conn, ~p"/admin/navigation")
|
||||
assert {:redirect, %{to: path}} = redirect
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "navigation editor" do
|
||||
setup %{conn: conn, user: user} do
|
||||
%{conn: log_in_user(conn, user)}
|
||||
end
|
||||
|
||||
test "renders with header and footer sections", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
assert html =~ "Navigation"
|
||||
assert html =~ "Header navigation"
|
||||
assert html =~ "Footer navigation"
|
||||
end
|
||||
|
||||
test "shows default header items", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
assert html =~ "Home"
|
||||
assert html =~ "Shop"
|
||||
assert html =~ "About"
|
||||
assert html =~ "Contact"
|
||||
end
|
||||
|
||||
test "shows default footer items", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
assert html =~ "Delivery & returns"
|
||||
assert html =~ "Privacy policy"
|
||||
assert html =~ "Terms of service"
|
||||
end
|
||||
|
||||
test "adding an item appends to list", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
render_click(view, "add_item", %{"section" => "header"})
|
||||
|
||||
# Should have 5 items now (4 defaults + 1 new)
|
||||
html = render(view)
|
||||
# Count header item forms by their phx-value-section attribute
|
||||
assert Regex.scan(~r/phx-value-section="header".*?phx-value-index/, html)
|
||||
|> length() >= 5
|
||||
end
|
||||
|
||||
test "removing an item removes from list", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
render_click(view, "remove_item", %{"section" => "header", "index" => "0"})
|
||||
|
||||
html = render(view)
|
||||
# "Home" should be gone (it was index 0)
|
||||
refute html =~ ~s(value="Home")
|
||||
# "Shop" should still be there
|
||||
assert html =~ ~s(value="Shop")
|
||||
end
|
||||
|
||||
test "moving item up reorders", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
render_click(view, "move_item", %{
|
||||
"section" => "header",
|
||||
"index" => "1",
|
||||
"dir" => "up"
|
||||
})
|
||||
|
||||
# Save and check order
|
||||
render_click(view, "save")
|
||||
|
||||
items = Settings.get_setting("header_nav")
|
||||
assert Enum.at(items, 0)["label"] == "Shop"
|
||||
assert Enum.at(items, 1)["label"] == "Home"
|
||||
end
|
||||
|
||||
test "custom pages appear in destination dropdown", %{conn: conn} do
|
||||
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
# Custom page should appear in the dropdown options
|
||||
assert html =~ "FAQ"
|
||||
assert html =~ ~s(value="/faq")
|
||||
end
|
||||
|
||||
test "save persists to settings", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
render_click(view, "remove_item", %{"section" => "footer", "index" => "0"})
|
||||
render_click(view, "save")
|
||||
|
||||
# Inline feedback shows "Saved"
|
||||
assert has_element?(view, ".admin-inline-feedback-saved", "Saved")
|
||||
|
||||
items = Settings.get_setting("footer_nav")
|
||||
assert length(items) == 3
|
||||
assert Enum.at(items, 0)["label"] == "Privacy policy"
|
||||
end
|
||||
|
||||
test "reset defaults restores original items", %{conn: conn} do
|
||||
# Save custom nav
|
||||
Settings.put_setting("header_nav", [%{"label" => "Only", "href" => "/only"}], "json")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
# Should show the custom item
|
||||
assert render(view) =~ ~s(value="Only")
|
||||
|
||||
render_click(view, "reset_defaults")
|
||||
|
||||
html = render(view)
|
||||
assert html =~ ~s(value="Home")
|
||||
assert html =~ ~s(value="Shop")
|
||||
refute html =~ ~s(value="Only")
|
||||
end
|
||||
|
||||
test "dirty flag appears after changes", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
refute has_element?(view, ".admin-badge-warning")
|
||||
|
||||
render_click(view, "add_item", %{"section" => "header"})
|
||||
|
||||
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
|
||||
end
|
||||
|
||||
test "dirty flag clears after save", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
render_click(view, "add_item", %{"section" => "header"})
|
||||
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")
|
||||
refute has_element?(view, ".admin-badge-warning")
|
||||
end
|
||||
|
||||
test "save button disabled when not dirty", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/navigation")
|
||||
|
||||
assert has_element?(view, "button[disabled]", "Save")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,7 @@ defmodule BerrypodWeb.Shop.ContentTest do
|
||||
setup do
|
||||
user_fixture()
|
||||
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
||||
Berrypod.Site.seed_defaults()
|
||||
:ok
|
||||
end
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ defmodule BerrypodWeb.Shop.HomeTest do
|
||||
setup do
|
||||
user_fixture()
|
||||
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
||||
Berrypod.Site.seed_defaults()
|
||||
|
||||
conn = provider_connection_fixture()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user