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/priv/repo/migrations/20240117042735_create_products.exs b/priv/repo/migrations/20240117042735_create_products.exs
new file mode 100644
index 0000000..22f9488
--- /dev/null
+++ b/priv/repo/migrations/20240117042735_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