add Site context with social links editor and site-wide settings
Some checks failed
deploy / deploy (push) Has been cancelled
Some checks failed
deploy / deploy (push) Has been cancelled
- Add Site context for managing site-wide content (social links, nav items, announcement bar, footer content) - Add SocialLink schema with URL normalization and platform auto-detection supporting 40+ platforms via host and 25+ via URI scheme - Add NavItem schema for header/footer navigation (editor UI coming next) - Add SiteEditor component with collapsible sections for each content type - Wire social links card block and footer to use database data - Filter empty URLs from display in shop components - Add DetailsPreserver hook to preserve collapsible section state - Add comprehensive tests for Site context and SocialLink functions - Remove unused helper functions from onboarding to fix compiler warnings - Move sync_edit_url_param helper to group handle_editor_event clauses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
233
test/berrypod/site_test.exs
Normal file
233
test/berrypod/site_test.exs
Normal file
@@ -0,0 +1,233 @@
|
||||
defmodule Berrypod.SiteTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
|
||||
alias Berrypod.Site
|
||||
alias Berrypod.Site.SocialLink
|
||||
|
||||
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 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})
|
||||
|
||||
links = Site.list_social_links()
|
||||
assert length(links) == 3
|
||||
assert Enum.map(links, & &1.platform) == ["instagram", "twitter", "github"]
|
||||
end
|
||||
|
||||
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"})
|
||||
|
||||
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"})
|
||||
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})
|
||||
|
||||
# Reorder: github, instagram, twitter
|
||||
Site.reorder_social_links([c.id, a.id, b.id])
|
||||
|
||||
links = Site.list_social_links()
|
||||
assert Enum.map(links, & &1.platform) == ["github", "instagram", "twitter"]
|
||||
end
|
||||
end
|
||||
|
||||
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})
|
||||
|
||||
[link] = Site.social_links_for_shop()
|
||||
assert link.platform == :instagram
|
||||
assert link.url == "https://instagram.com/test"
|
||||
assert link.label == "Instagram"
|
||||
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: "custom", url: "", position: 1})
|
||||
{:ok, _} = Site.create_social_link(%{platform: "twitter", url: nil, position: 2})
|
||||
|
||||
links = Site.social_links_for_shop()
|
||||
assert length(links) == 1
|
||||
assert hd(links).platform == :instagram
|
||||
end
|
||||
end
|
||||
|
||||
describe "SocialLink.normalize_url/1" do
|
||||
test "trims whitespace" do
|
||||
assert SocialLink.normalize_url(" https://example.com ") == "https://example.com"
|
||||
end
|
||||
|
||||
test "adds https:// to bare domains" do
|
||||
assert SocialLink.normalize_url("example.com") == "https://example.com"
|
||||
assert SocialLink.normalize_url("github.com/user") == "https://github.com/user"
|
||||
end
|
||||
|
||||
test "preserves http://" do
|
||||
assert SocialLink.normalize_url("http://example.com") == "http://example.com"
|
||||
end
|
||||
|
||||
test "preserves https://" do
|
||||
assert SocialLink.normalize_url("https://example.com") == "https://example.com"
|
||||
end
|
||||
|
||||
test "preserves app deep links" do
|
||||
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
|
||||
|
||||
test "preserves mailto: and tel:" do
|
||||
assert SocialLink.normalize_url("mailto:user@example.com") == "mailto:user@example.com"
|
||||
assert SocialLink.normalize_url("tel:+1234567890") == "tel:+1234567890"
|
||||
end
|
||||
|
||||
test "handles nil and empty strings" do
|
||||
assert SocialLink.normalize_url(nil) == nil
|
||||
assert SocialLink.normalize_url("") == ""
|
||||
assert SocialLink.normalize_url(" ") == ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "SocialLink.detect_platform/1" do
|
||||
test "detects common platforms from URLs" do
|
||||
assert SocialLink.detect_platform("https://github.com/user") == "github"
|
||||
assert SocialLink.detect_platform("https://twitter.com/user") == "twitter"
|
||||
assert SocialLink.detect_platform("https://x.com/user") == "twitter"
|
||||
assert SocialLink.detect_platform("https://instagram.com/user") == "instagram"
|
||||
assert SocialLink.detect_platform("https://bsky.app/profile/user") == "bluesky"
|
||||
assert SocialLink.detect_platform("https://mastodon.social/@user") == "mastodon"
|
||||
end
|
||||
|
||||
test "handles www prefix" do
|
||||
assert SocialLink.detect_platform("https://www.github.com/user") == "github"
|
||||
assert SocialLink.detect_platform("https://www.youtube.com/watch?v=123") == "youtube"
|
||||
end
|
||||
|
||||
test "detects subdomain-based platforms" do
|
||||
assert SocialLink.detect_platform("https://artist.bandcamp.com") == "bandcamp"
|
||||
assert SocialLink.detect_platform("https://writer.substack.com") == "substack"
|
||||
assert SocialLink.detect_platform("https://user.tumblr.com") == "tumblr"
|
||||
end
|
||||
|
||||
test "detects platforms from URI schemes" do
|
||||
assert SocialLink.detect_platform("tg://resolve?domain=channel") == "telegram"
|
||||
assert SocialLink.detect_platform("spotify:track:123") == "spotify"
|
||||
assert SocialLink.detect_platform("rss://feed.example.com") == "rss"
|
||||
assert SocialLink.detect_platform("discord://discord.com/channels/123") == "discord"
|
||||
end
|
||||
|
||||
test "normalizes URLs before detection" do
|
||||
assert SocialLink.detect_platform("github.com/user") == "github"
|
||||
assert SocialLink.detect_platform(" twitter.com/user ") == "twitter"
|
||||
end
|
||||
|
||||
test "returns custom for unknown domains" do
|
||||
assert SocialLink.detect_platform("https://example.com") == "custom"
|
||||
assert SocialLink.detect_platform("https://my-personal-site.org") == "custom"
|
||||
end
|
||||
|
||||
test "returns nil for invalid input" do
|
||||
assert SocialLink.detect_platform("") == nil
|
||||
assert SocialLink.detect_platform(nil) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "SocialLink changeset validation" do
|
||||
test "requires platform" do
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{url: "https://example.com"})
|
||||
assert %{platform: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates platform is in allowed list" do
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "invalid_platform"})
|
||||
assert %{platform: ["is invalid"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "allows empty URL" do
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: ""})
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "allows nil URL" do
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: nil})
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "validates URL has a scheme" do
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "custom", url: "no-scheme.com"})
|
||||
assert %{url: ["must be a valid URL"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "accepts URLs with any scheme" do
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "telegram", url: "tg://resolve"})
|
||||
assert changeset.valid?
|
||||
|
||||
changeset = SocialLink.changeset(%SocialLink{}, %{platform: "rss", url: "rss://feed.example.com"})
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
|
||||
describe "announcement settings" do
|
||||
test "stores and retrieves announcement text" do
|
||||
Site.set_announcement_text("Free shipping!")
|
||||
assert Site.announcement_text() == "Free shipping!"
|
||||
end
|
||||
|
||||
test "stores and retrieves announcement link" do
|
||||
Site.set_announcement_link("/delivery")
|
||||
assert Site.announcement_link() == "/delivery"
|
||||
end
|
||||
|
||||
test "stores and retrieves announcement style" do
|
||||
Site.set_announcement_style("sale")
|
||||
assert Site.announcement_style() == "sale"
|
||||
end
|
||||
|
||||
test "defaults to empty string for text and link" do
|
||||
assert Site.announcement_text() == ""
|
||||
assert Site.announcement_link() == ""
|
||||
end
|
||||
|
||||
test "defaults to info for style" do
|
||||
assert Site.announcement_style() == "info"
|
||||
end
|
||||
end
|
||||
|
||||
describe "footer settings" do
|
||||
test "stores and retrieves footer about text" do
|
||||
Site.set_footer_about("About us blurb")
|
||||
assert Site.footer_about() == "About us blurb"
|
||||
end
|
||||
|
||||
test "stores and retrieves footer copyright" do
|
||||
Site.set_footer_copyright("© 2024 My Shop")
|
||||
assert Site.footer_copyright() == "© 2024 My Shop"
|
||||
end
|
||||
|
||||
test "stores and retrieves newsletter visibility" do
|
||||
Site.set_show_newsletter(false)
|
||||
assert Site.show_newsletter?() == false
|
||||
|
||||
Site.set_show_newsletter(true)
|
||||
assert Site.show_newsletter?() == true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
||||
import Phoenix.LiveViewTest
|
||||
import Berrypod.AccountsFixtures
|
||||
|
||||
alias Berrypod.{Pages, Settings}
|
||||
alias Berrypod.{Pages, Settings, Site}
|
||||
alias Berrypod.Pages.PageCache
|
||||
|
||||
setup do
|
||||
@@ -16,6 +16,9 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
||||
|
||||
describe "header navigation" do
|
||||
test "renders default items", %{conn: conn} do
|
||||
# Seed defaults if not already present
|
||||
Site.seed_defaults()
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
assert html =~ "Home"
|
||||
@@ -24,15 +27,15 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
||||
assert html =~ "Contact"
|
||||
end
|
||||
|
||||
test "renders from saved settings", %{conn: conn} do
|
||||
Settings.put_setting(
|
||||
"header_nav",
|
||||
[
|
||||
%{"label" => "Blog", "href" => "/blog", "slug" => "blog"},
|
||||
%{"label" => "FAQ", "href" => "/faq", "slug" => "faq"}
|
||||
],
|
||||
"json"
|
||||
)
|
||||
test "renders from database nav items", %{conn: conn} do
|
||||
# Clear existing and add custom items
|
||||
for item <- Site.list_nav_items("header"), do: Site.delete_nav_item(item)
|
||||
|
||||
{:ok, _} =
|
||||
Site.create_nav_item(%{location: "header", label: "Blog", url: "/blog", position: 0})
|
||||
|
||||
{:ok, _} =
|
||||
Site.create_nav_item(%{location: "header", label: "FAQ", url: "/faq", position: 1})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
@@ -44,6 +47,8 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
||||
|
||||
describe "footer navigation" do
|
||||
test "renders default items", %{conn: conn} do
|
||||
Site.seed_defaults()
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
assert html =~ "Delivery & returns"
|
||||
@@ -51,15 +56,25 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
||||
assert html =~ "Terms of service"
|
||||
end
|
||||
|
||||
test "renders from saved settings", %{conn: conn} do
|
||||
Settings.put_setting(
|
||||
"footer_nav",
|
||||
[
|
||||
%{"label" => "Returns", "href" => "/returns", "slug" => "returns"},
|
||||
%{"label" => "Shipping", "href" => "/shipping", "slug" => "shipping"}
|
||||
],
|
||||
"json"
|
||||
)
|
||||
test "renders from database nav items", %{conn: conn} do
|
||||
# Clear existing and add custom items
|
||||
for item <- Site.list_nav_items("footer"), do: Site.delete_nav_item(item)
|
||||
|
||||
{:ok, _} =
|
||||
Site.create_nav_item(%{
|
||||
location: "footer",
|
||||
label: "Returns",
|
||||
url: "/returns",
|
||||
position: 0
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
Site.create_nav_item(%{
|
||||
location: "footer",
|
||||
label: "Shipping",
|
||||
url: "/shipping",
|
||||
position: 1
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
@@ -71,16 +86,21 @@ defmodule BerrypodWeb.Shop.NavigationTest do
|
||||
|
||||
describe "custom page in navigation" do
|
||||
test "renders when added to header nav", %{conn: conn} do
|
||||
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
||||
{:ok, page} = Pages.create_custom_page(%{slug: "faq", title: "FAQ"})
|
||||
|
||||
Settings.put_setting(
|
||||
"header_nav",
|
||||
[
|
||||
%{"label" => "Home", "href" => "/", "slug" => "home"},
|
||||
%{"label" => "FAQ", "href" => "/faq", "slug" => "faq"}
|
||||
],
|
||||
"json"
|
||||
)
|
||||
# Clear existing header nav and add custom items
|
||||
for item <- Site.list_nav_items("header"), do: Site.delete_nav_item(item)
|
||||
|
||||
{:ok, _} = Site.create_nav_item(%{location: "header", label: "Home", url: "/", position: 0})
|
||||
|
||||
{:ok, _} =
|
||||
Site.create_nav_item(%{
|
||||
location: "header",
|
||||
label: "FAQ",
|
||||
url: "/faq",
|
||||
page_id: page.id,
|
||||
position: 1
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
||||
|> render_click()
|
||||
|
||||
# Save
|
||||
view |> element("button[phx-click='editor_save']") |> render_click()
|
||||
view |> element("button[phx-click='editor_save_all']") |> render_click()
|
||||
|
||||
# Verify persistence
|
||||
updated = Pages.get_page("home")
|
||||
@@ -277,7 +277,7 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
||||
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|
||||
|> render_click()
|
||||
|
||||
view |> element("button[phx-click='editor_save']") |> render_click()
|
||||
view |> element("button[phx-click='editor_save_all']") |> render_click()
|
||||
|
||||
# After save, undo should be disabled
|
||||
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")
|
||||
|
||||
Reference in New Issue
Block a user