From fe29c1ad360e5793749285878b9b0bce751f7884 Mon Sep 17 00:00:00 2001 From: Jamey Greenwood Date: Mon, 19 Jan 2026 23:38:22 +0000 Subject: [PATCH] feat: add product sorting to collection pages with tests Add sort functionality to /collections/:slug pages with 6 sort options (featured, newest, price ascending/descending, name A-Z/Z-A). Sort selection persists across category navigation via URL query params. Refactor handle_params to be DRY using load_collection/1 helper. Add comprehensive unit tests for PreviewData category functions and LiveView tests for the Collection page sorting and navigation. Co-Authored-By: Claude Opus 4.5 --- .../live/shop_live/collection.ex | 145 +++++++++++------ .../theme/preview_data_test.exs | 77 +++++++++ .../live/shop_live/collection_test.exs | 154 ++++++++++++++++++ 3 files changed, 329 insertions(+), 47 deletions(-) create mode 100644 test/simpleshop_theme_web/live/shop_live/collection_test.exs diff --git a/lib/simpleshop_theme_web/live/shop_live/collection.ex b/lib/simpleshop_theme_web/live/shop_live/collection.ex index a30a6fa..8e940c9 100644 --- a/lib/simpleshop_theme_web/live/shop_live/collection.ex +++ b/lib/simpleshop_theme_web/live/shop_live/collection.ex @@ -5,6 +5,15 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do alias SimpleshopTheme.Media alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData} + @sort_options [ + {"featured", "Featured"}, + {"newest", "Newest"}, + {"price_asc", "Price: Low to High"}, + {"price_desc", "Price: High to Low"}, + {"name_asc", "Name: A-Z"}, + {"name_desc", "Name: Z-A"} + ] + @impl true def mount(_params, _session, socket) do theme_settings = Settings.get_theme_settings() @@ -32,40 +41,62 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do |> assign(:cart_count, 0) |> assign(:cart_subtotal, "£0.00") |> assign(:categories, PreviewData.categories()) + |> assign(:sort_options, @sort_options) + |> assign(:current_sort, "featured") {:ok, socket} end @impl true - def handle_params(%{"slug" => "all"}, _uri, socket) do - {:noreply, - socket - |> assign(:page_title, "All Products") - |> assign(:collection_title, "All Products") - |> assign(:current_category, nil) - |> assign(:products, PreviewData.products())} - end + def handle_params(%{"slug" => slug} = params, _uri, socket) do + sort = params["sort"] || "featured" - def handle_params(%{"slug" => slug}, _uri, socket) do - case PreviewData.category_by_slug(slug) do - nil -> + case load_collection(slug) do + {:ok, title, category, products} -> + {:noreply, + socket + |> assign(:page_title, title) + |> assign(:collection_title, title) + |> assign(:current_category, category) + |> assign(:current_sort, sort) + |> assign(:products, sort_products(products, sort))} + + :not_found -> {:noreply, socket |> put_flash(:error, "Collection not found") |> push_navigate(to: ~p"/collections/all")} - - category -> - products = PreviewData.products_by_category(slug) - - {:noreply, - socket - |> assign(:page_title, category.name) - |> assign(:collection_title, category.name) - |> assign(:current_category, category) - |> assign(:products, products)} end end + defp load_collection("all") do + {:ok, "All Products", nil, PreviewData.products()} + end + + defp load_collection(slug) do + case PreviewData.category_by_slug(slug) do + nil -> :not_found + category -> {:ok, category.name, category, PreviewData.products_by_category(slug)} + end + end + + @impl true + def handle_event("sort_changed", %{"sort" => sort}, socket) do + slug = if socket.assigns.current_category, do: socket.assigns.current_category.slug, else: "all" + {:noreply, push_patch(socket, to: ~p"/collections/#{slug}?sort=#{sort}")} + end + + defp sort_products(products, "featured"), do: products + defp sort_products(products, "newest"), do: Enum.reverse(products) + defp sort_products(products, "price_asc"), do: Enum.sort_by(products, & &1.price) + defp sort_products(products, "price_desc"), do: Enum.sort_by(products, & &1.price, :desc) + defp sort_products(products, "name_asc"), do: Enum.sort_by(products, & &1.name) + defp sort_products(products, "name_desc"), do: Enum.sort_by(products, & &1.name, :desc) + defp sort_products(products, _), do: products + + defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}" + defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}" + @impl true def render(assigns) do ~H""" @@ -92,7 +123,12 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do />
- <.collection_filter_bar categories={@categories} current_slug={@current_category && @current_category.slug} /> + <.collection_filter_bar + categories={@categories} + current_slug={@current_category && @current_category.slug} + sort_options={@sort_options} + current_sort={@current_sort} + /> <%= for product <- @products do %> @@ -128,42 +164,57 @@ defmodule SimpleshopThemeWeb.ShopLive.Collection do defp collection_filter_bar(assigns) do ~H""" - + +
+ +
+
""" end end diff --git a/test/simpleshop_theme/theme/preview_data_test.exs b/test/simpleshop_theme/theme/preview_data_test.exs index 6bc8a5d..7efc144 100644 --- a/test/simpleshop_theme/theme/preview_data_test.exs +++ b/test/simpleshop_theme/theme/preview_data_test.exs @@ -198,4 +198,81 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do assert PreviewData.has_real_products?() == false end end + + describe "category_by_slug/1" do + test "returns category when slug exists" do + category = PreviewData.category_by_slug("art-prints") + + assert category != nil + assert category.slug == "art-prints" + assert is_binary(category.name) + end + + test "returns nil when slug does not exist" do + assert PreviewData.category_by_slug("nonexistent") == nil + end + + test "finds all known categories by slug" do + categories = PreviewData.categories() + + for category <- categories do + found = PreviewData.category_by_slug(category.slug) + assert found != nil + assert found.id == category.id + end + end + end + + describe "products_by_category/1" do + test "returns all products for nil" do + all_products = PreviewData.products() + filtered = PreviewData.products_by_category(nil) + + assert filtered == all_products + end + + test "returns all products for 'all'" do + all_products = PreviewData.products() + filtered = PreviewData.products_by_category("all") + + assert filtered == all_products + end + + test "returns empty list for nonexistent category" do + assert PreviewData.products_by_category("nonexistent") == [] + end + + test "returns only products matching the category" do + category = List.first(PreviewData.categories()) + products = PreviewData.products_by_category(category.slug) + + assert is_list(products) + + for product <- products do + assert product.category == category.name + end + end + + test "products are filtered correctly for each category" do + categories = PreviewData.categories() + + for category <- categories do + products = PreviewData.products_by_category(category.slug) + + for product <- products do + assert product.category == category.name + end + end + end + + test "all products belong to at least one category" do + all_products = PreviewData.products() + categories = PreviewData.categories() + category_names = Enum.map(categories, & &1.name) + + for product <- all_products do + assert product.category in category_names + end + end + end end diff --git a/test/simpleshop_theme_web/live/shop_live/collection_test.exs b/test/simpleshop_theme_web/live/shop_live/collection_test.exs new file mode 100644 index 0000000..7cbb21a --- /dev/null +++ b/test/simpleshop_theme_web/live/shop_live/collection_test.exs @@ -0,0 +1,154 @@ +defmodule SimpleshopThemeWeb.ShopLive.CollectionTest do + use SimpleshopThemeWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias SimpleshopTheme.Theme.PreviewData + + describe "Collection page" do + test "renders collection page for /collections/all", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/collections/all") + + assert html =~ "All Products" + end + + test "renders collection page for specific category", %{conn: conn} do + category = List.first(PreviewData.categories()) + {:ok, _view, html} = live(conn, ~p"/collections/#{category.slug}") + + assert html =~ category.name + end + + test "displays products", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/collections/all") + + products = PreviewData.products() + first_product = List.first(products) + + assert html =~ first_product.name + end + + test "displays category filter buttons", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/collections/all") + + categories = PreviewData.categories() + + for category <- categories do + assert html =~ category.name + end + end + + test "displays sort dropdown", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/collections/all") + + assert html =~ "Featured" + assert html =~ "Newest" + assert html =~ "Price: Low to High" + assert html =~ "Price: High to Low" + assert html =~ "Name: A-Z" + assert html =~ "Name: Z-A" + end + + test "redirects to /collections/all for unknown category", %{conn: conn} do + {:error, {:live_redirect, %{to: to, flash: flash}}} = + live(conn, ~p"/collections/nonexistent") + + assert to == "/collections/all" + assert flash["error"] == "Collection not found" + end + + test "filters products by category", %{conn: conn} do + category = List.first(PreviewData.categories()) + {:ok, _view, html} = live(conn, ~p"/collections/#{category.slug}") + + products = PreviewData.products_by_category(category.slug) + + for product <- products do + assert html =~ product.name + end + end + end + + describe "Sorting" do + test "sorts by price ascending", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/collections/all") + + html = + view + |> element("form[phx-change='sort_changed']") + |> render_change(%{sort: "price_asc"}) + + assert html =~ "Price: Low to High" + end + + test "sorts by price descending", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/collections/all") + + html = + view + |> element("form[phx-change='sort_changed']") + |> render_change(%{sort: "price_desc"}) + + assert html =~ "Price: High to Low" + end + + test "sorts by name ascending", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/collections/all") + + html = + view + |> element("form[phx-change='sort_changed']") + |> render_change(%{sort: "name_asc"}) + + assert html =~ "Name: A-Z" + end + + test "sorts by name descending", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/collections/all") + + html = + view + |> element("form[phx-change='sort_changed']") + |> render_change(%{sort: "name_desc"}) + + assert html =~ "Name: Z-A" + end + + test "sort parameter is preserved in URL", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/collections/all?sort=price_asc") + + html = render(view) + + assert html =~ "selected" + end + + test "default sort is featured", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/collections/all") + + assert html =~ ~r/]*value="featured"[^>]*selected/ + end + end + + describe "Navigation" do + test "category links preserve sort order", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/collections/all?sort=price_desc") + + category = List.first(PreviewData.categories()) + assert html =~ "/collections/#{category.slug}?sort=price_desc" + end + + test "All button link preserves sort order", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/collections/art-prints?sort=name_asc") + + assert html =~ "/collections/all?sort=name_asc" + end + + test "featured sort does not include query param in links", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/collections/all") + + category = List.first(PreviewData.categories()) + assert html =~ ~s(href="/collections/#{category.slug}") + refute html =~ "/collections/#{category.slug}?sort=featured" + end + end +end