From 5dbc8833ad936d572d161a40ca8337b4bdc88c0f Mon Sep 17 00:00:00 2001 From: Daniel Tinivella Date: Sat, 10 Feb 2024 13:28:30 -0300 Subject: [PATCH] add LiveProducts to Pento --- lib/pento/catalog.ex | 104 ++++++++++++++++ lib/pento/catalog/product.ex | 21 ++++ .../live/product_live/form_component.ex | 93 ++++++++++++++ lib/pento_web/live/product_live/index.ex | 47 ++++++++ .../live/product_live/index.html.heex | 44 +++++++ lib/pento_web/live/product_live/show.ex | 21 ++++ .../live/product_live/show.html.heex | 29 +++++ lib/pento_web/router.ex | 7 ++ .../20240210162636_create_products.exs | 16 +++ test/pento/catalog_test.exs | 65 ++++++++++ test/pento_web/live/product_live_test.exs | 113 ++++++++++++++++++ test/support/fixtures/catalog_fixtures.ex | 28 +++++ 12 files changed, 588 insertions(+) create mode 100644 lib/pento/catalog.ex create mode 100644 lib/pento/catalog/product.ex create mode 100644 lib/pento_web/live/product_live/form_component.ex create mode 100644 lib/pento_web/live/product_live/index.ex create mode 100644 lib/pento_web/live/product_live/index.html.heex create mode 100644 lib/pento_web/live/product_live/show.ex create mode 100644 lib/pento_web/live/product_live/show.html.heex create mode 100644 priv/repo/migrations/20240210162636_create_products.exs create mode 100644 test/pento/catalog_test.exs create mode 100644 test/pento_web/live/product_live_test.exs create mode 100644 test/support/fixtures/catalog_fixtures.ex diff --git a/lib/pento/catalog.ex b/lib/pento/catalog.ex new file mode 100644 index 0000000..8971055 --- /dev/null +++ b/lib/pento/catalog.ex @@ -0,0 +1,104 @@ +defmodule Pento.Catalog do + @moduledoc """ + The Catalog context. + """ + + import Ecto.Query, warn: false + alias Pento.Repo + + alias Pento.Catalog.Product + + @doc """ + Returns the list of products. + + ## Examples + + iex> list_products() + [%Product{}, ...] + + """ + def list_products do + Repo.all(Product) + end + + @doc """ + Gets a single product. + + Raises `Ecto.NoResultsError` if the Product does not exist. + + ## Examples + + iex> get_product!(123) + %Product{} + + iex> get_product!(456) + ** (Ecto.NoResultsError) + + """ + def get_product!(id), do: Repo.get!(Product, id) + + @doc """ + Creates a product. + + ## Examples + + iex> create_product(%{field: value}) + {:ok, %Product{}} + + iex> create_product(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_product(attrs \\ %{}) do + %Product{} + |> Product.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a product. + + ## Examples + + iex> update_product(product, %{field: new_value}) + {:ok, %Product{}} + + iex> update_product(product, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_product(%Product{} = product, attrs) do + product + |> Product.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a product. + + ## Examples + + iex> delete_product(product) + {:ok, %Product{}} + + iex> delete_product(product) + {:error, %Ecto.Changeset{}} + + """ + def delete_product(%Product{} = product) do + Repo.delete(product) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking product changes. + + ## Examples + + iex> change_product(product) + %Ecto.Changeset{data: %Product{}} + + """ + def change_product(%Product{} = product, attrs \\ %{}) do + Product.changeset(product, attrs) + end +end diff --git a/lib/pento/catalog/product.ex b/lib/pento/catalog/product.ex new file mode 100644 index 0000000..e337515 --- /dev/null +++ b/lib/pento/catalog/product.ex @@ -0,0 +1,21 @@ +defmodule Pento.Catalog.Product do + use Ecto.Schema + import Ecto.Changeset + + schema "products" do + field :name, :string + field :description, :string + field :unit_price, :float + field :sku, :integer + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(product, attrs) do + product + |> cast(attrs, [:name, :description, :unit_price, :sku]) + |> validate_required([:name, :description, :unit_price, :sku]) + |> unique_constraint(:sku) + end +end diff --git a/lib/pento_web/live/product_live/form_component.ex b/lib/pento_web/live/product_live/form_component.ex new file mode 100644 index 0000000..3aad9a2 --- /dev/null +++ b/lib/pento_web/live/product_live/form_component.ex @@ -0,0 +1,93 @@ +defmodule PentoWeb.ProductLive.FormComponent do + use PentoWeb, :live_component + + alias Pento.Catalog + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + <%= @title %> + <:subtitle>Use this form to manage product records in your database. + + + <.simple_form + for={@form} + id="product-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:name]} type="text" label="Name" /> + <.input field={@form[:description]} type="text" label="Description" /> + <.input field={@form[:unit_price]} type="number" label="Unit price" step="any" /> + <.input field={@form[:sku]} type="number" label="Sku" /> + <:actions> + <.button phx-disable-with="Saving...">Save Product + + +
+ """ + end + + @impl true + def update(%{product: product} = assigns, socket) do + changeset = Catalog.change_product(product) + + {:ok, + socket + |> assign(assigns) + |> assign_form(changeset)} + end + + @impl true + def handle_event("validate", %{"product" => product_params}, socket) do + changeset = + socket.assigns.product + |> Catalog.change_product(product_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"product" => product_params}, socket) do + save_product(socket, socket.assigns.action, product_params) + end + + defp save_product(socket, :edit, product_params) do + case Catalog.update_product(socket.assigns.product, product_params) do + {:ok, product} -> + notify_parent({:saved, product}) + + {:noreply, + socket + |> put_flash(:info, "Product updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_product(socket, :new, product_params) do + case Catalog.create_product(product_params) do + {:ok, product} -> + notify_parent({:saved, product}) + + {:noreply, + socket + |> put_flash(:info, "Product created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :form, to_form(changeset)) + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) +end diff --git a/lib/pento_web/live/product_live/index.ex b/lib/pento_web/live/product_live/index.ex new file mode 100644 index 0000000..767ba22 --- /dev/null +++ b/lib/pento_web/live/product_live/index.ex @@ -0,0 +1,47 @@ +defmodule PentoWeb.ProductLive.Index do + use PentoWeb, :live_view + + alias Pento.Catalog + alias Pento.Catalog.Product + + @impl true + def mount(_params, _session, socket) do + {:ok, stream(socket, :products, Catalog.list_products())} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Product") + |> assign(:product, Catalog.get_product!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Product") + |> assign(:product, %Product{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Products") + |> assign(:product, nil) + end + + @impl true + def handle_info({PentoWeb.ProductLive.FormComponent, {:saved, product}}, socket) do + {:noreply, stream_insert(socket, :products, product)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + product = Catalog.get_product!(id) + {:ok, _} = Catalog.delete_product(product) + + {:noreply, stream_delete(socket, :products, product)} + end +end diff --git a/lib/pento_web/live/product_live/index.html.heex b/lib/pento_web/live/product_live/index.html.heex new file mode 100644 index 0000000..d44de83 --- /dev/null +++ b/lib/pento_web/live/product_live/index.html.heex @@ -0,0 +1,44 @@ +<.header> + Listing Products + <:actions> + <.link patch={~p"/products/new"}> + <.button>New Product + + + + +<.table + id="products" + rows={@streams.products} + row_click={fn {_id, product} -> JS.navigate(~p"/products/#{product}") end} +> + <:col :let={{_id, product}} label="Name"><%= product.name %> + <:col :let={{_id, product}} label="Description"><%= product.description %> + <:col :let={{_id, product}} label="Unit price"><%= product.unit_price %> + <:col :let={{_id, product}} label="Sku"><%= product.sku %> + <:action :let={{_id, product}}> +
+ <.link navigate={~p"/products/#{product}"}>Show +
+ <.link patch={~p"/products/#{product}/edit"}>Edit + + <:action :let={{id, product}}> + <.link + phx-click={JS.push("delete", value: %{id: product.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + + +<.modal :if={@live_action in [:new, :edit]} id="product-modal" show on_cancel={JS.patch(~p"/products")}> + <.live_component + module={PentoWeb.ProductLive.FormComponent} + id={@product.id || :new} + title={@page_title} + action={@live_action} + product={@product} + patch={~p"/products"} + /> + diff --git a/lib/pento_web/live/product_live/show.ex b/lib/pento_web/live/product_live/show.ex new file mode 100644 index 0000000..3982d79 --- /dev/null +++ b/lib/pento_web/live/product_live/show.ex @@ -0,0 +1,21 @@ +defmodule PentoWeb.ProductLive.Show do + use PentoWeb, :live_view + + alias Pento.Catalog + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:product, Catalog.get_product!(id))} + end + + defp page_title(:show), do: "Show Product" + defp page_title(:edit), do: "Edit Product" +end diff --git a/lib/pento_web/live/product_live/show.html.heex b/lib/pento_web/live/product_live/show.html.heex new file mode 100644 index 0000000..bca9fde --- /dev/null +++ b/lib/pento_web/live/product_live/show.html.heex @@ -0,0 +1,29 @@ +<.header> + Product <%= @product.id %> + <:subtitle>This is a product record from your database. + <:actions> + <.link patch={~p"/products/#{@product}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit product + + + + +<.list> + <:item title="Name"><%= @product.name %> + <:item title="Description"><%= @product.description %> + <:item title="Unit price"><%= @product.unit_price %> + <:item title="Sku"><%= @product.sku %> + + +<.back navigate={~p"/products"}>Back to products + +<.modal :if={@live_action == :edit} id="product-modal" show on_cancel={JS.patch(~p"/products/#{@product}")}> + <.live_component + module={PentoWeb.ProductLive.FormComponent} + id={@product.id} + title={@page_title} + action={@live_action} + product={@product} + patch={~p"/products/#{@product}"} + /> + diff --git a/lib/pento_web/router.ex b/lib/pento_web/router.ex index 20f928d..7d2e4c9 100644 --- a/lib/pento_web/router.ex +++ b/lib/pento_web/router.ex @@ -26,6 +26,13 @@ defmodule PentoWeb.Router do get "/", PageController, :home live "/guess", WrongLive + + live "/products", ProductLive.Index, :index + live "/products/new", ProductLive.Index, :new + live "/products/:id/edit", ProductLive.Index, :edit + + live "/products/:id", ProductLive.Show, :show + live "/products/:id/show/edit", ProductLive.Show, :edit end # Other scopes may use custom stacks. diff --git a/priv/repo/migrations/20240210162636_create_products.exs b/priv/repo/migrations/20240210162636_create_products.exs new file mode 100644 index 0000000..22f9488 --- /dev/null +++ b/priv/repo/migrations/20240210162636_create_products.exs @@ -0,0 +1,16 @@ +defmodule Pento.Repo.Migrations.CreateProducts do + use Ecto.Migration + + def change do + create table(:products) do + add :name, :string + add :description, :string + add :unit_price, :float + add :sku, :integer + + timestamps(type: :utc_datetime) + end + + create unique_index(:products, [:sku]) + end +end diff --git a/test/pento/catalog_test.exs b/test/pento/catalog_test.exs new file mode 100644 index 0000000..862e9fe --- /dev/null +++ b/test/pento/catalog_test.exs @@ -0,0 +1,65 @@ +defmodule Pento.CatalogTest do + use Pento.DataCase + + alias Pento.Catalog + + describe "products" do + alias Pento.Catalog.Product + + import Pento.CatalogFixtures + + @invalid_attrs %{name: nil, description: nil, unit_price: nil, sku: nil} + + test "list_products/0 returns all products" do + product = product_fixture() + assert Catalog.list_products() == [product] + end + + test "get_product!/1 returns the product with given id" do + product = product_fixture() + assert Catalog.get_product!(product.id) == product + end + + test "create_product/1 with valid data creates a product" do + valid_attrs = %{name: "some name", description: "some description", unit_price: 120.5, sku: 42} + + assert {:ok, %Product{} = product} = Catalog.create_product(valid_attrs) + assert product.name == "some name" + assert product.description == "some description" + assert product.unit_price == 120.5 + assert product.sku == 42 + end + + test "create_product/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Catalog.create_product(@invalid_attrs) + end + + test "update_product/2 with valid data updates the product" do + product = product_fixture() + update_attrs = %{name: "some updated name", description: "some updated description", unit_price: 456.7, sku: 43} + + assert {:ok, %Product{} = product} = Catalog.update_product(product, update_attrs) + assert product.name == "some updated name" + assert product.description == "some updated description" + assert product.unit_price == 456.7 + assert product.sku == 43 + end + + test "update_product/2 with invalid data returns error changeset" do + product = product_fixture() + assert {:error, %Ecto.Changeset{}} = Catalog.update_product(product, @invalid_attrs) + assert product == Catalog.get_product!(product.id) + end + + test "delete_product/1 deletes the product" do + product = product_fixture() + assert {:ok, %Product{}} = Catalog.delete_product(product) + assert_raise Ecto.NoResultsError, fn -> Catalog.get_product!(product.id) end + end + + test "change_product/1 returns a product changeset" do + product = product_fixture() + assert %Ecto.Changeset{} = Catalog.change_product(product) + end + end +end diff --git a/test/pento_web/live/product_live_test.exs b/test/pento_web/live/product_live_test.exs new file mode 100644 index 0000000..e927756 --- /dev/null +++ b/test/pento_web/live/product_live_test.exs @@ -0,0 +1,113 @@ +defmodule PentoWeb.ProductLiveTest do + use PentoWeb.ConnCase + + import Phoenix.LiveViewTest + import Pento.CatalogFixtures + + @create_attrs %{name: "some name", description: "some description", unit_price: 120.5, sku: 42} + @update_attrs %{name: "some updated name", description: "some updated description", unit_price: 456.7, sku: 43} + @invalid_attrs %{name: nil, description: nil, unit_price: nil, sku: nil} + + defp create_product(_) do + product = product_fixture() + %{product: product} + end + + describe "Index" do + setup [:create_product] + + test "lists all products", %{conn: conn, product: product} do + {:ok, _index_live, html} = live(conn, ~p"/products") + + assert html =~ "Listing Products" + assert html =~ product.name + end + + test "saves new product", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/products") + + assert index_live |> element("a", "New Product") |> render_click() =~ + "New Product" + + assert_patch(index_live, ~p"/products/new") + + assert index_live + |> form("#product-form", product: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#product-form", product: @create_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/products") + + html = render(index_live) + assert html =~ "Product created successfully" + assert html =~ "some name" + end + + test "updates product in listing", %{conn: conn, product: product} do + {:ok, index_live, _html} = live(conn, ~p"/products") + + assert index_live |> element("#products-#{product.id} a", "Edit") |> render_click() =~ + "Edit Product" + + assert_patch(index_live, ~p"/products/#{product}/edit") + + assert index_live + |> form("#product-form", product: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#product-form", product: @update_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/products") + + html = render(index_live) + assert html =~ "Product updated successfully" + assert html =~ "some updated name" + end + + test "deletes product in listing", %{conn: conn, product: product} do + {:ok, index_live, _html} = live(conn, ~p"/products") + + assert index_live |> element("#products-#{product.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#products-#{product.id}") + end + end + + describe "Show" do + setup [:create_product] + + test "displays product", %{conn: conn, product: product} do + {:ok, _show_live, html} = live(conn, ~p"/products/#{product}") + + assert html =~ "Show Product" + assert html =~ product.name + end + + test "updates product within modal", %{conn: conn, product: product} do + {:ok, show_live, _html} = live(conn, ~p"/products/#{product}") + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Product" + + assert_patch(show_live, ~p"/products/#{product}/show/edit") + + assert show_live + |> form("#product-form", product: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert show_live + |> form("#product-form", product: @update_attrs) + |> render_submit() + + assert_patch(show_live, ~p"/products/#{product}") + + html = render(show_live) + assert html =~ "Product updated successfully" + assert html =~ "some updated name" + end + end +end diff --git a/test/support/fixtures/catalog_fixtures.ex b/test/support/fixtures/catalog_fixtures.ex new file mode 100644 index 0000000..f4f559d --- /dev/null +++ b/test/support/fixtures/catalog_fixtures.ex @@ -0,0 +1,28 @@ +defmodule Pento.CatalogFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Pento.Catalog` context. + """ + + @doc """ + Generate a unique product sku. + """ + def unique_product_sku, do: System.unique_integer([:positive]) + + @doc """ + Generate a product. + """ + def product_fixture(attrs \\ %{}) do + {:ok, product} = + attrs + |> Enum.into(%{ + description: "some description", + name: "some name", + sku: unique_product_sku(), + unit_price: 120.5 + }) + |> Pento.Catalog.create_product() + + product + end +end