From 851665ef60fa8992f064abfa77cf988573dc645b Mon Sep 17 00:00:00 2001 From: Bent Witthold Date: Tue, 21 Apr 2026 13:29:13 +0200 Subject: [PATCH] After 'mix phx.gen.live Items Item items name:string description:string info:string amount:integer factor:float type:string' --- lib/generic_rest_server/items.ex | 147 ++++++++++++++++++ lib/generic_rest_server/items/item.ex | 26 ++++ .../live/item_live/form.ex | 103 ++++++++++++ .../live/item_live/index.ex | 78 ++++++++++ .../live/item_live/show.ex | 69 ++++++++ lib/generic_rest_server_web/router.ex | 5 + .../20260421112128_create_items.exs | 20 +++ test/generic_rest_server/items_test.exs | 101 ++++++++++++ .../live/item_live_test.exs | 125 +++++++++++++++ test/support/fixtures/items_fixtures.ex | 24 +++ 10 files changed, 698 insertions(+) create mode 100644 lib/generic_rest_server/items.ex create mode 100644 lib/generic_rest_server/items/item.ex create mode 100644 lib/generic_rest_server_web/live/item_live/form.ex create mode 100644 lib/generic_rest_server_web/live/item_live/index.ex create mode 100644 lib/generic_rest_server_web/live/item_live/show.ex create mode 100644 priv/repo/migrations/20260421112128_create_items.exs create mode 100644 test/generic_rest_server/items_test.exs create mode 100644 test/generic_rest_server_web/live/item_live_test.exs create mode 100644 test/support/fixtures/items_fixtures.ex diff --git a/lib/generic_rest_server/items.ex b/lib/generic_rest_server/items.ex new file mode 100644 index 0000000..5808aca --- /dev/null +++ b/lib/generic_rest_server/items.ex @@ -0,0 +1,147 @@ +defmodule GenericRestServer.Items do + @moduledoc """ + The Items context. + """ + + import Ecto.Query, warn: false + alias GenericRestServer.Repo + + alias GenericRestServer.Items.Item + alias GenericRestServer.Accounts.Scope + + @doc """ + Subscribes to scoped notifications about any item changes. + + The broadcasted messages match the pattern: + + * {:created, %Item{}} + * {:updated, %Item{}} + * {:deleted, %Item{}} + + """ + def subscribe_items(%Scope{} = scope) do + key = scope.user.id + + Phoenix.PubSub.subscribe(GenericRestServer.PubSub, "user:#{key}:items") + end + + defp broadcast_item(%Scope{} = scope, message) do + key = scope.user.id + + Phoenix.PubSub.broadcast(GenericRestServer.PubSub, "user:#{key}:items", message) + end + + @doc """ + Returns the list of items. + + ## Examples + + iex> list_items(scope) + [%Item{}, ...] + + """ + def list_items(%Scope{} = scope) do + Repo.all_by(Item, user_id: scope.user.id) + end + + @doc """ + Gets a single item. + + Raises `Ecto.NoResultsError` if the Item does not exist. + + ## Examples + + iex> get_item!(scope, 123) + %Item{} + + iex> get_item!(scope, 456) + ** (Ecto.NoResultsError) + + """ + def get_item!(%Scope{} = scope, id) do + Repo.get_by!(Item, id: id, user_id: scope.user.id) + end + + @doc """ + Creates a item. + + ## Examples + + iex> create_item(scope, %{field: value}) + {:ok, %Item{}} + + iex> create_item(scope, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_item(%Scope{} = scope, attrs) do + with {:ok, item = %Item{}} <- + %Item{} + |> Item.changeset(attrs, scope) + |> Repo.insert() do + broadcast_item(scope, {:created, item}) + {:ok, item} + end + end + + @doc """ + Updates a item. + + ## Examples + + iex> update_item(scope, item, %{field: new_value}) + {:ok, %Item{}} + + iex> update_item(scope, item, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_item(%Scope{} = scope, %Item{} = item, attrs) do + true = item.user_id == scope.user.id + + with {:ok, item = %Item{}} <- + item + |> Item.changeset(attrs, scope) + |> Repo.update() do + broadcast_item(scope, {:updated, item}) + {:ok, item} + end + end + + @doc """ + Deletes a item. + + ## Examples + + iex> delete_item(scope, item) + {:ok, %Item{}} + + iex> delete_item(scope, item) + {:error, %Ecto.Changeset{}} + + """ + def delete_item(%Scope{} = scope, %Item{} = item) do + true = item.user_id == scope.user.id + + with {:ok, item = %Item{}} <- + Repo.delete(item) do + broadcast_item(scope, {:deleted, item}) + {:ok, item} + end + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking item changes. + + ## Examples + + iex> change_item(scope, item) + %Ecto.Changeset{data: %Item{}} + + """ + def change_item(%Scope{} = scope, %Item{} = item, attrs \\ %{}) do + true = item.user_id == scope.user.id + + Item.changeset(item, attrs, scope) + end +end diff --git a/lib/generic_rest_server/items/item.ex b/lib/generic_rest_server/items/item.ex new file mode 100644 index 0000000..f5b2ca7 --- /dev/null +++ b/lib/generic_rest_server/items/item.ex @@ -0,0 +1,26 @@ +defmodule GenericRestServer.Items.Item do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "items" do + field :name, :string + field :description, :string + field :info, :string + field :amount, :integer + field :factor, :float + field :type, :string + field :user_id, :binary_id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(item, attrs, user_scope) do + item + |> cast(attrs, [:name, :description, :info, :amount, :factor, :type]) + |> validate_required([:name, :description, :info, :amount, :factor, :type]) + |> put_change(:user_id, user_scope.user.id) + end +end diff --git a/lib/generic_rest_server_web/live/item_live/form.ex b/lib/generic_rest_server_web/live/item_live/form.ex new file mode 100644 index 0000000..c2b862c --- /dev/null +++ b/lib/generic_rest_server_web/live/item_live/form.ex @@ -0,0 +1,103 @@ +defmodule GenericRestServerWeb.ItemLive.Form do + use GenericRestServerWeb, :live_view + + alias GenericRestServer.Items + alias GenericRestServer.Items.Item + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle>Use this form to manage item records in your database. + + + <.form for={@form} id="item-form" 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[:info]} type="text" label="Info" /> + <.input field={@form[:amount]} type="number" label="Amount" /> + <.input field={@form[:factor]} type="number" label="Factor" step="any" /> + <.input field={@form[:type]} type="text" label="Type" /> +
+ <.button phx-disable-with="Saving..." variant="primary">Save Item + <.button navigate={return_path(@current_scope, @return_to, @item)}>Cancel +
+ +
+ """ + end + + @impl true + def mount(params, _session, socket) do + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> apply_action(socket.assigns.live_action, params)} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + defp apply_action(socket, :edit, %{"id" => id}) do + item = Items.get_item!(socket.assigns.current_scope, id) + + socket + |> assign(:page_title, "Edit Item") + |> assign(:item, item) + |> assign(:form, to_form(Items.change_item(socket.assigns.current_scope, item))) + end + + defp apply_action(socket, :new, _params) do + item = %Item{user_id: socket.assigns.current_scope.user.id} + + socket + |> assign(:page_title, "New Item") + |> assign(:item, item) + |> assign(:form, to_form(Items.change_item(socket.assigns.current_scope, item))) + end + + @impl true + def handle_event("validate", %{"item" => item_params}, socket) do + changeset = Items.change_item(socket.assigns.current_scope, socket.assigns.item, item_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"item" => item_params}, socket) do + save_item(socket, socket.assigns.live_action, item_params) + end + + defp save_item(socket, :edit, item_params) do + case Items.update_item(socket.assigns.current_scope, socket.assigns.item, item_params) do + {:ok, item} -> + {:noreply, + socket + |> put_flash(:info, "Item updated successfully") + |> push_navigate( + to: return_path(socket.assigns.current_scope, socket.assigns.return_to, item) + )} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_item(socket, :new, item_params) do + case Items.create_item(socket.assigns.current_scope, item_params) do + {:ok, item} -> + {:noreply, + socket + |> put_flash(:info, "Item created successfully") + |> push_navigate( + to: return_path(socket.assigns.current_scope, socket.assigns.return_to, item) + )} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp return_path(_scope, "index", _item), do: ~p"/items" + defp return_path(_scope, "show", item), do: ~p"/items/#{item}" +end diff --git a/lib/generic_rest_server_web/live/item_live/index.ex b/lib/generic_rest_server_web/live/item_live/index.ex new file mode 100644 index 0000000..eb23e9a --- /dev/null +++ b/lib/generic_rest_server_web/live/item_live/index.ex @@ -0,0 +1,78 @@ +defmodule GenericRestServerWeb.ItemLive.Index do + use GenericRestServerWeb, :live_view + + alias GenericRestServer.Items + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Items + <:actions> + <.button variant="primary" navigate={~p"/items/new"}> + <.icon name="hero-plus" /> New Item + + + + + <.table + id="items" + rows={@streams.items} + row_click={fn {_id, item} -> JS.navigate(~p"/items/#{item}") end} + > + <:col :let={{_id, item}} label="Name">{item.name} + <:col :let={{_id, item}} label="Description">{item.description} + <:col :let={{_id, item}} label="Info">{item.info} + <:col :let={{_id, item}} label="Amount">{item.amount} + <:col :let={{_id, item}} label="Factor">{item.factor} + <:col :let={{_id, item}} label="Type">{item.type} + <:action :let={{_id, item}}> +
+ <.link navigate={~p"/items/#{item}"}>Show +
+ <.link navigate={~p"/items/#{item}/edit"}>Edit + + <:action :let={{id, item}}> + <.link + phx-click={JS.push("delete", value: %{id: item.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Items.subscribe_items(socket.assigns.current_scope) + end + + {:ok, + socket + |> assign(:page_title, "Listing Items") + |> stream(:items, list_items(socket.assigns.current_scope))} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + item = Items.get_item!(socket.assigns.current_scope, id) + {:ok, _} = Items.delete_item(socket.assigns.current_scope, item) + + {:noreply, stream_delete(socket, :items, item)} + end + + @impl true + def handle_info({type, %GenericRestServer.Items.Item{}}, socket) + when type in [:created, :updated, :deleted] do + {:noreply, stream(socket, :items, list_items(socket.assigns.current_scope), reset: true)} + end + + defp list_items(current_scope) do + Items.list_items(current_scope) + end +end diff --git a/lib/generic_rest_server_web/live/item_live/show.ex b/lib/generic_rest_server_web/live/item_live/show.ex new file mode 100644 index 0000000..5e906a4 --- /dev/null +++ b/lib/generic_rest_server_web/live/item_live/show.ex @@ -0,0 +1,69 @@ +defmodule GenericRestServerWeb.ItemLive.Show do + use GenericRestServerWeb, :live_view + + alias GenericRestServer.Items + + @impl true + def render(assigns) do + ~H""" + + <.header> + Item {@item.id} + <:subtitle>This is a item record from your database. + <:actions> + <.button navigate={~p"/items"}> + <.icon name="hero-arrow-left" /> + + <.button variant="primary" navigate={~p"/items/#{@item}/edit?return_to=show"}> + <.icon name="hero-pencil-square" /> Edit item + + + + + <.list> + <:item title="Name">{@item.name} + <:item title="Description">{@item.description} + <:item title="Info">{@item.info} + <:item title="Amount">{@item.amount} + <:item title="Factor">{@item.factor} + <:item title="Type">{@item.type} + + + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + if connected?(socket) do + Items.subscribe_items(socket.assigns.current_scope) + end + + {:ok, + socket + |> assign(:page_title, "Show Item") + |> assign(:item, Items.get_item!(socket.assigns.current_scope, id))} + end + + @impl true + def handle_info( + {:updated, %GenericRestServer.Items.Item{id: id} = item}, + %{assigns: %{item: %{id: id}}} = socket + ) do + {:noreply, assign(socket, :item, item)} + end + + def handle_info( + {:deleted, %GenericRestServer.Items.Item{id: id}}, + %{assigns: %{item: %{id: id}}} = socket + ) do + {:noreply, + socket + |> put_flash(:error, "The current item was deleted.") + |> push_navigate(to: ~p"/items")} + end + + def handle_info({type, %GenericRestServer.Items.Item{}}, socket) + when type in [:created, :updated, :deleted] do + {:noreply, socket} + end +end diff --git a/lib/generic_rest_server_web/router.ex b/lib/generic_rest_server_web/router.ex index dd054ee..59afe04 100644 --- a/lib/generic_rest_server_web/router.ex +++ b/lib/generic_rest_server_web/router.ex @@ -54,6 +54,11 @@ defmodule GenericRestServerWeb.Router do on_mount: [{GenericRestServerWeb.UserAuth, :require_authenticated}] do live "/users/settings", UserLive.Settings, :edit live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email + + live "/items", ItemLive.Index, :index + live "/items/new", ItemLive.Form, :new + live "/items/:id", ItemLive.Show, :show + live "/items/:id/edit", ItemLive.Form, :edit end post "/users/update-password", UserSessionController, :update_password diff --git a/priv/repo/migrations/20260421112128_create_items.exs b/priv/repo/migrations/20260421112128_create_items.exs new file mode 100644 index 0000000..da439bf --- /dev/null +++ b/priv/repo/migrations/20260421112128_create_items.exs @@ -0,0 +1,20 @@ +defmodule GenericRestServer.Repo.Migrations.CreateItems do + use Ecto.Migration + + def change do + create table(:items, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string + add :description, :string + add :info, :string + add :amount, :integer + add :factor, :float + add :type, :string + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:items, [:user_id]) + end +end diff --git a/test/generic_rest_server/items_test.exs b/test/generic_rest_server/items_test.exs new file mode 100644 index 0000000..cffeb13 --- /dev/null +++ b/test/generic_rest_server/items_test.exs @@ -0,0 +1,101 @@ +defmodule GenericRestServer.ItemsTest do + use GenericRestServer.DataCase + + alias GenericRestServer.Items + + describe "items" do + alias GenericRestServer.Items.Item + + import GenericRestServer.AccountsFixtures, only: [user_scope_fixture: 0] + import GenericRestServer.ItemsFixtures + + @invalid_attrs %{info: nil, name: nil, type: nil, description: nil, amount: nil, factor: nil} + + test "list_items/1 returns all scoped items" do + scope = user_scope_fixture() + other_scope = user_scope_fixture() + item = item_fixture(scope) + other_item = item_fixture(other_scope) + assert Items.list_items(scope) == [item] + assert Items.list_items(other_scope) == [other_item] + end + + test "get_item!/2 returns the item with given id" do + scope = user_scope_fixture() + item = item_fixture(scope) + other_scope = user_scope_fixture() + assert Items.get_item!(scope, item.id) == item + assert_raise Ecto.NoResultsError, fn -> Items.get_item!(other_scope, item.id) end + end + + test "create_item/2 with valid data creates a item" do + valid_attrs = %{info: "some info", name: "some name", type: "some type", description: "some description", amount: 42, factor: 120.5} + scope = user_scope_fixture() + + assert {:ok, %Item{} = item} = Items.create_item(scope, valid_attrs) + assert item.info == "some info" + assert item.name == "some name" + assert item.type == "some type" + assert item.description == "some description" + assert item.amount == 42 + assert item.factor == 120.5 + assert item.user_id == scope.user.id + end + + test "create_item/2 with invalid data returns error changeset" do + scope = user_scope_fixture() + assert {:error, %Ecto.Changeset{}} = Items.create_item(scope, @invalid_attrs) + end + + test "update_item/3 with valid data updates the item" do + scope = user_scope_fixture() + item = item_fixture(scope) + update_attrs = %{info: "some updated info", name: "some updated name", type: "some updated type", description: "some updated description", amount: 43, factor: 456.7} + + assert {:ok, %Item{} = item} = Items.update_item(scope, item, update_attrs) + assert item.info == "some updated info" + assert item.name == "some updated name" + assert item.type == "some updated type" + assert item.description == "some updated description" + assert item.amount == 43 + assert item.factor == 456.7 + end + + test "update_item/3 with invalid scope raises" do + scope = user_scope_fixture() + other_scope = user_scope_fixture() + item = item_fixture(scope) + + assert_raise MatchError, fn -> + Items.update_item(other_scope, item, %{}) + end + end + + test "update_item/3 with invalid data returns error changeset" do + scope = user_scope_fixture() + item = item_fixture(scope) + assert {:error, %Ecto.Changeset{}} = Items.update_item(scope, item, @invalid_attrs) + assert item == Items.get_item!(scope, item.id) + end + + test "delete_item/2 deletes the item" do + scope = user_scope_fixture() + item = item_fixture(scope) + assert {:ok, %Item{}} = Items.delete_item(scope, item) + assert_raise Ecto.NoResultsError, fn -> Items.get_item!(scope, item.id) end + end + + test "delete_item/2 with invalid scope raises" do + scope = user_scope_fixture() + other_scope = user_scope_fixture() + item = item_fixture(scope) + assert_raise MatchError, fn -> Items.delete_item(other_scope, item) end + end + + test "change_item/2 returns a item changeset" do + scope = user_scope_fixture() + item = item_fixture(scope) + assert %Ecto.Changeset{} = Items.change_item(scope, item) + end + end +end diff --git a/test/generic_rest_server_web/live/item_live_test.exs b/test/generic_rest_server_web/live/item_live_test.exs new file mode 100644 index 0000000..6d4296c --- /dev/null +++ b/test/generic_rest_server_web/live/item_live_test.exs @@ -0,0 +1,125 @@ +defmodule GenericRestServerWeb.ItemLiveTest do + use GenericRestServerWeb.ConnCase + + import Phoenix.LiveViewTest + import GenericRestServer.ItemsFixtures + + @create_attrs %{info: "some info", name: "some name", type: "some type", description: "some description", amount: 42, factor: 120.5} + @update_attrs %{info: "some updated info", name: "some updated name", type: "some updated type", description: "some updated description", amount: 43, factor: 456.7} + @invalid_attrs %{info: nil, name: nil, type: nil, description: nil, amount: nil, factor: nil} + + setup :register_and_log_in_user + + defp create_item(%{scope: scope}) do + item = item_fixture(scope) + + %{item: item} + end + + describe "Index" do + setup [:create_item] + + test "lists all items", %{conn: conn, item: item} do + {:ok, _index_live, html} = live(conn, ~p"/items") + + assert html =~ "Listing Items" + assert html =~ item.name + end + + test "saves new item", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/items") + + assert {:ok, form_live, _} = + index_live + |> element("a", "New Item") + |> render_click() + |> follow_redirect(conn, ~p"/items/new") + + assert render(form_live) =~ "New Item" + + assert form_live + |> form("#item-form", item: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, index_live, _html} = + form_live + |> form("#item-form", item: @create_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/items") + + html = render(index_live) + assert html =~ "Item created successfully" + assert html =~ "some name" + end + + test "updates item in listing", %{conn: conn, item: item} do + {:ok, index_live, _html} = live(conn, ~p"/items") + + assert {:ok, form_live, _html} = + index_live + |> element("#items-#{item.id} a", "Edit") + |> render_click() + |> follow_redirect(conn, ~p"/items/#{item}/edit") + + assert render(form_live) =~ "Edit Item" + + assert form_live + |> form("#item-form", item: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, index_live, _html} = + form_live + |> form("#item-form", item: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/items") + + html = render(index_live) + assert html =~ "Item updated successfully" + assert html =~ "some updated name" + end + + test "deletes item in listing", %{conn: conn, item: item} do + {:ok, index_live, _html} = live(conn, ~p"/items") + + assert index_live |> element("#items-#{item.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#items-#{item.id}") + end + end + + describe "Show" do + setup [:create_item] + + test "displays item", %{conn: conn, item: item} do + {:ok, _show_live, html} = live(conn, ~p"/items/#{item}") + + assert html =~ "Show Item" + assert html =~ item.name + end + + test "updates item and returns to show", %{conn: conn, item: item} do + {:ok, show_live, _html} = live(conn, ~p"/items/#{item}") + + assert {:ok, form_live, _} = + show_live + |> element("a", "Edit") + |> render_click() + |> follow_redirect(conn, ~p"/items/#{item}/edit?return_to=show") + + assert render(form_live) =~ "Edit Item" + + assert form_live + |> form("#item-form", item: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, show_live, _html} = + form_live + |> form("#item-form", item: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/items/#{item}") + + html = render(show_live) + assert html =~ "Item updated successfully" + assert html =~ "some updated name" + end + end +end diff --git a/test/support/fixtures/items_fixtures.ex b/test/support/fixtures/items_fixtures.ex new file mode 100644 index 0000000..3ec9e6a --- /dev/null +++ b/test/support/fixtures/items_fixtures.ex @@ -0,0 +1,24 @@ +defmodule GenericRestServer.ItemsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `GenericRestServer.Items` context. + """ + + @doc """ + Generate a item. + """ + def item_fixture(scope, attrs \\ %{}) do + attrs = + Enum.into(attrs, %{ + amount: 42, + description: "some description", + factor: 120.5, + info: "some info", + name: "some name", + type: "some type" + }) + + {:ok, item} = GenericRestServer.Items.create_item(scope, attrs) + item + end +end