diff --git a/lib/dinge/accounts/user.ex b/lib/dinge/accounts/user.ex index 89854d3..c3d3352 100644 --- a/lib/dinge/accounts/user.ex +++ b/lib/dinge/accounts/user.ex @@ -8,6 +8,8 @@ defmodule Dinge.Accounts.User do field :hashed_password, :string, redact: true field :confirmed_at, :naive_datetime + has_many :dings, Dinge.Ledger.Ding + timestamps(type: :utc_datetime) end diff --git a/lib/dinge/ledger.ex b/lib/dinge/ledger.ex new file mode 100644 index 0000000..4b4a34e --- /dev/null +++ b/lib/dinge/ledger.ex @@ -0,0 +1,111 @@ +defmodule Dinge.Ledger do + @moduledoc """ + The Ledger context. + """ + + import Ecto.Query, warn: false + alias Dinge.Repo + + alias Dinge.Ledger.Ding + + @doc """ + Returns the list of dings. + + ## Examples + + iex> list_dings() + [%Ding{}, ...] + + """ + def list_dings do + Repo.all(Ding) + end + + def list_dings_by_user(user) do + query = + from d in Ding, + where: d.user_id == ^user.id + Repo.all(query) + end + + @doc """ + Gets a single ding. + + Raises `Ecto.NoResultsError` if the Ding does not exist. + + ## Examples + + iex> get_ding!(123) + %Ding{} + + iex> get_ding!(456) + ** (Ecto.NoResultsError) + + """ + def get_ding!(id), do: Repo.get!(Ding, id) + + @doc """ + Creates a ding. + + ## Examples + + iex> create_ding(%{field: value}) + {:ok, %Ding{}} + + iex> create_ding(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_ding(attrs \\ %{}) do + %Ding{} + |> Ding.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a ding. + + ## Examples + + iex> update_ding(ding, %{field: new_value}) + {:ok, %Ding{}} + + iex> update_ding(ding, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_ding(%Ding{} = ding, attrs) do + ding + |> Ding.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a ding. + + ## Examples + + iex> delete_ding(ding) + {:ok, %Ding{}} + + iex> delete_ding(ding) + {:error, %Ecto.Changeset{}} + + """ + def delete_ding(%Ding{} = ding) do + Repo.delete(ding) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking ding changes. + + ## Examples + + iex> change_ding(ding) + %Ecto.Changeset{data: %Ding{}} + + """ + def change_ding(%Ding{} = ding, attrs \\ %{}) do + Ding.changeset(ding, attrs) + end +end diff --git a/lib/dinge/ledger/ding.ex b/lib/dinge/ledger/ding.ex new file mode 100644 index 0000000..fb8f271 --- /dev/null +++ b/lib/dinge/ledger/ding.ex @@ -0,0 +1,24 @@ +defmodule Dinge.Ledger.Ding do + use Ecto.Schema + import Ecto.Changeset + + schema "dings" do + field :count, :integer + field :status, Ecto.Enum, values: [:active, :inactive, :finished, :hidden, :deleted] + field :title, :string + field :target_count, :integer + field :ding_type, Ecto.Enum, values: [:countdown, :countup] + field :target_type, Ecto.Enum, values: [:default, :daily, :weekly, :monthly] + belongs_to :user, Dinge.Accounts.User + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(ding, attrs) do + ding + |> cast(attrs, [:title, :count, :target_count, :ding_type, :target_type, :status, :user_id]) + |> cast_assoc(:user) + |> validate_required([:title, :count, :target_count, :ding_type, :target_type, :status, :user_id]) + end +end diff --git a/lib/dinge_web/live/ding_live/form_component.ex b/lib/dinge_web/live/ding_live/form_component.ex new file mode 100644 index 0000000..1ba4c57 --- /dev/null +++ b/lib/dinge_web/live/ding_live/form_component.ex @@ -0,0 +1,115 @@ +defmodule DingeWeb.DingLive.FormComponent do + use DingeWeb, :live_component + + alias Dinge.Ledger + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + <%= @title %> + <:subtitle>Use this form to manage ding records in your database. + + + <.simple_form + for={@form} + id="ding-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:title]} type="text" label="Title" /> + <.input field={@form[:count]} type="number" label="Count" value="0" /> + <.input field={@form[:target_count]} type="number" label="Target count" value="10" /> + <.input + field={@form[:ding_type]} + type="select" + label="Ding type" + prompt="Choose a value" + options={Ecto.Enum.values(Dinge.Ledger.Ding, :ding_type)} + /> + <.input + field={@form[:target_type]} + type="select" + label="Target type" + prompt="Choose a value" + options={Ecto.Enum.values(Dinge.Ledger.Ding, :target_type)} + /> + <.input + field={@form[:status]} + type="select" + label="Status" + prompt="Choose a value" + options={Ecto.Enum.values(Dinge.Ledger.Ding, :status)} + /> + <:actions> + <.button phx-disable-with="Saving...">Save Ding + + +
+ """ + end + + @impl true + def update(%{ding: ding} = assigns, socket) do + changeset = Ledger.change_ding(ding) + + {:ok, + socket + |> assign(assigns) + |> assign_form(changeset)} + end + + @impl true + def handle_event("validate", %{"ding" => ding_params}, socket) do + changeset = + socket.assigns.ding + |> Ledger.change_ding(ding_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"ding" => ding_params}, socket) do + save_ding(socket, socket.assigns.action, ding_params) + end + + defp save_ding(socket, :edit, ding_params) do + case Ledger.update_ding(socket.assigns.ding, ding_params) do + {:ok, ding} -> + notify_parent({:saved, ding}) + + {:noreply, + socket + |> put_flash(:info, "Ding updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_ding(socket, :new, ding_params) do + ding_params = Map.put(ding_params, "user_id", socket.assigns.user.id) + + case Ledger.create_ding(ding_params) do + {:ok, ding} -> + notify_parent({:saved, ding}) + + {:noreply, + socket + |> put_flash(:info, "Ding 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/dinge_web/live/ding_live/index.ex b/lib/dinge_web/live/ding_live/index.ex new file mode 100644 index 0000000..584d71b --- /dev/null +++ b/lib/dinge_web/live/ding_live/index.ex @@ -0,0 +1,47 @@ +defmodule DingeWeb.DingLive.Index do + use DingeWeb, :live_view + + alias Dinge.Ledger + alias Dinge.Ledger.Ding + + @impl true + def mount(_params, _session, socket) do + {:ok, stream(socket, :dings, Ledger.list_dings_by_user(socket.assigns.current_user))} + 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 Ding") + |> assign(:ding, Ledger.get_ding!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Ding") + |> assign(:ding, %Ding{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Dings") + |> assign(:ding, nil) + end + + @impl true + def handle_info({DingeWeb.DingLive.FormComponent, {:saved, ding}}, socket) do + {:noreply, stream_insert(socket, :dings, ding)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + ding = Ledger.get_ding!(id) + {:ok, _} = Ledger.delete_ding(ding) + + {:noreply, stream_delete(socket, :dings, ding)} + end +end diff --git a/lib/dinge_web/live/ding_live/index.html.heex b/lib/dinge_web/live/ding_live/index.html.heex new file mode 100644 index 0000000..2525e67 --- /dev/null +++ b/lib/dinge_web/live/ding_live/index.html.heex @@ -0,0 +1,47 @@ +<.header> + Listing Dings + <:actions> + <.link patch={~p"/dings/new"}> + <.button>New Ding + + + + +<.table + id="dings" + rows={@streams.dings} + row_click={fn {_id, ding} -> JS.navigate(~p"/dings/#{ding}") end} +> + <:col :let={{_id, ding}} label="Title"><%= ding.title %> + <:col :let={{_id, ding}} label="Count"><%= ding.count %> + <:col :let={{_id, ding}} label="Target count"><%= ding.target_count %> + <:col :let={{_id, ding}} label="Ding type"><%= ding.ding_type %> + <:col :let={{_id, ding}} label="Target type"><%= ding.target_type %> + <:col :let={{_id, ding}} label="Status"><%= ding.status %> + <:action :let={{_id, ding}}> +
+ <.link navigate={~p"/dings/#{ding}"}>Show +
+ <.link patch={~p"/dings/#{ding}/edit"}>Edit + + <:action :let={{id, ding}}> + <.link + phx-click={JS.push("delete", value: %{id: ding.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + + +<.modal :if={@live_action in [:new, :edit]} id="ding-modal" show on_cancel={JS.patch(~p"/dings")}> + <.live_component + module={DingeWeb.DingLive.FormComponent} + id={@ding.id || :new} + title={@page_title} + action={@live_action} + ding={@ding} + user={@current_user} + patch={~p"/dings"} + /> + diff --git a/lib/dinge_web/live/ding_live/show.ex b/lib/dinge_web/live/ding_live/show.ex new file mode 100644 index 0000000..71a150e --- /dev/null +++ b/lib/dinge_web/live/ding_live/show.ex @@ -0,0 +1,21 @@ +defmodule DingeWeb.DingLive.Show do + use DingeWeb, :live_view + + alias Dinge.Ledger + + @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(:ding, Ledger.get_ding!(id))} + end + + defp page_title(:show), do: "Show Ding" + defp page_title(:edit), do: "Edit Ding" +end diff --git a/lib/dinge_web/live/ding_live/show.html.heex b/lib/dinge_web/live/ding_live/show.html.heex new file mode 100644 index 0000000..81eaaec --- /dev/null +++ b/lib/dinge_web/live/ding_live/show.html.heex @@ -0,0 +1,31 @@ +<.header> + Ding <%= @ding.id %> + <:subtitle>This is a ding record from your database. + <:actions> + <.link patch={~p"/dings/#{@ding}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit ding + + + + +<.list> + <:item title="Title"><%= @ding.title %> + <:item title="Count"><%= @ding.count %> + <:item title="Target count"><%= @ding.target_count %> + <:item title="Ding type"><%= @ding.ding_type %> + <:item title="Target type"><%= @ding.target_type %> + <:item title="Status"><%= @ding.status %> + + +<.back navigate={~p"/dings"}>Back to dings + +<.modal :if={@live_action == :edit} id="ding-modal" show on_cancel={JS.patch(~p"/dings/#{@ding}")}> + <.live_component + module={DingeWeb.DingLive.FormComponent} + id={@ding.id} + title={@page_title} + action={@live_action} + ding={@ding} + patch={~p"/dings/#{@ding}"} + /> + diff --git a/lib/dinge_web/router.ex b/lib/dinge_web/router.ex index 7b62762..7b12548 100644 --- a/lib/dinge_web/router.ex +++ b/lib/dinge_web/router.ex @@ -23,6 +23,20 @@ defmodule DingeWeb.Router do get "/", PageController, :home end + scope "/", DingeWeb do + pipe_through [:browser, :require_authenticated_user] + + live_session :dings_authenticated_user, + on_mount: [{DingeWeb.UserAuth, :ensure_authenticated}] do + live "/dings", DingLive.Index, :index + live "/dings/new", DingLive.Index, :new + live "/dings/:id/edit", DingLive.Index, :edit + + live "/dings/:id", DingLive.Show, :show + live "/dings/:id/show/edit", DingLive.Show, :edit + end + end + # Other scopes may use custom stacks. # scope "/api", DingeWeb do # pipe_through :api diff --git a/priv/repo/migrations/20240226112724_create_dings.exs b/priv/repo/migrations/20240226112724_create_dings.exs new file mode 100644 index 0000000..fbf8f52 --- /dev/null +++ b/priv/repo/migrations/20240226112724_create_dings.exs @@ -0,0 +1,19 @@ +defmodule Dinge.Repo.Migrations.CreateDings do + use Ecto.Migration + + def change do + create table(:dings) do + add :title, :string + add :count, :integer + add :target_count, :integer + add :ding_type, :string + add :target_type, :string + add :status, :string + add :user_id, references(:users, on_delete: :nothing) + + timestamps(type: :utc_datetime) + end + + create index(:dings, [:user_id]) + end +end diff --git a/test/dinge/ledger_test.exs b/test/dinge/ledger_test.exs new file mode 100644 index 0000000..0c59457 --- /dev/null +++ b/test/dinge/ledger_test.exs @@ -0,0 +1,69 @@ +defmodule Dinge.LedgerTest do + use Dinge.DataCase + + alias Dinge.Ledger + + describe "dings" do + alias Dinge.Ledger.Ding + + import Dinge.LedgerFixtures + + @invalid_attrs %{count: nil, status: nil, title: nil, target_count: nil, ding_type: nil, target_type: nil} + + test "list_dings/0 returns all dings" do + ding = ding_fixture() + assert Ledger.list_dings() == [ding] + end + + test "get_ding!/1 returns the ding with given id" do + ding = ding_fixture() + assert Ledger.get_ding!(ding.id) == ding + end + + test "create_ding/1 with valid data creates a ding" do + valid_attrs = %{count: 42, status: :active, title: "some title", target_count: 42, ding_type: :countdown, target_type: :default} + + assert {:ok, %Ding{} = ding} = Ledger.create_ding(valid_attrs) + assert ding.count == 42 + assert ding.status == :active + assert ding.title == "some title" + assert ding.target_count == 42 + assert ding.ding_type == :countdown + assert ding.target_type == :default + end + + test "create_ding/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Ledger.create_ding(@invalid_attrs) + end + + test "update_ding/2 with valid data updates the ding" do + ding = ding_fixture() + update_attrs = %{count: 43, status: :inactive, title: "some updated title", target_count: 43, ding_type: :countup, target_type: :daily} + + assert {:ok, %Ding{} = ding} = Ledger.update_ding(ding, update_attrs) + assert ding.count == 43 + assert ding.status == :inactive + assert ding.title == "some updated title" + assert ding.target_count == 43 + assert ding.ding_type == :countup + assert ding.target_type == :daily + end + + test "update_ding/2 with invalid data returns error changeset" do + ding = ding_fixture() + assert {:error, %Ecto.Changeset{}} = Ledger.update_ding(ding, @invalid_attrs) + assert ding == Ledger.get_ding!(ding.id) + end + + test "delete_ding/1 deletes the ding" do + ding = ding_fixture() + assert {:ok, %Ding{}} = Ledger.delete_ding(ding) + assert_raise Ecto.NoResultsError, fn -> Ledger.get_ding!(ding.id) end + end + + test "change_ding/1 returns a ding changeset" do + ding = ding_fixture() + assert %Ecto.Changeset{} = Ledger.change_ding(ding) + end + end +end diff --git a/test/dinge_web/live/ding_live_test.exs b/test/dinge_web/live/ding_live_test.exs new file mode 100644 index 0000000..d9fabb7 --- /dev/null +++ b/test/dinge_web/live/ding_live_test.exs @@ -0,0 +1,113 @@ +defmodule DingeWeb.DingLiveTest do + use DingeWeb.ConnCase + + import Phoenix.LiveViewTest + import Dinge.LedgerFixtures + + @create_attrs %{count: 42, status: :active, title: "some title", target_count: 42, ding_type: :countdown, target_type: :default} + @update_attrs %{count: 43, status: :inactive, title: "some updated title", target_count: 43, ding_type: :countup, target_type: :daily} + @invalid_attrs %{count: nil, status: nil, title: nil, target_count: nil, ding_type: nil, target_type: nil} + + defp create_ding(_) do + ding = ding_fixture() + %{ding: ding} + end + + describe "Index" do + setup [:create_ding] + + test "lists all dings", %{conn: conn, ding: ding} do + {:ok, _index_live, html} = live(conn, ~p"/dings") + + assert html =~ "Listing Dings" + assert html =~ ding.title + end + + test "saves new ding", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/dings") + + assert index_live |> element("a", "New Ding") |> render_click() =~ + "New Ding" + + assert_patch(index_live, ~p"/dings/new") + + assert index_live + |> form("#ding-form", ding: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#ding-form", ding: @create_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/dings") + + html = render(index_live) + assert html =~ "Ding created successfully" + assert html =~ "some title" + end + + test "updates ding in listing", %{conn: conn, ding: ding} do + {:ok, index_live, _html} = live(conn, ~p"/dings") + + assert index_live |> element("#dings-#{ding.id} a", "Edit") |> render_click() =~ + "Edit Ding" + + assert_patch(index_live, ~p"/dings/#{ding}/edit") + + assert index_live + |> form("#ding-form", ding: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#ding-form", ding: @update_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/dings") + + html = render(index_live) + assert html =~ "Ding updated successfully" + assert html =~ "some updated title" + end + + test "deletes ding in listing", %{conn: conn, ding: ding} do + {:ok, index_live, _html} = live(conn, ~p"/dings") + + assert index_live |> element("#dings-#{ding.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#dings-#{ding.id}") + end + end + + describe "Show" do + setup [:create_ding] + + test "displays ding", %{conn: conn, ding: ding} do + {:ok, _show_live, html} = live(conn, ~p"/dings/#{ding}") + + assert html =~ "Show Ding" + assert html =~ ding.title + end + + test "updates ding within modal", %{conn: conn, ding: ding} do + {:ok, show_live, _html} = live(conn, ~p"/dings/#{ding}") + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Ding" + + assert_patch(show_live, ~p"/dings/#{ding}/show/edit") + + assert show_live + |> form("#ding-form", ding: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert show_live + |> form("#ding-form", ding: @update_attrs) + |> render_submit() + + assert_patch(show_live, ~p"/dings/#{ding}") + + html = render(show_live) + assert html =~ "Ding updated successfully" + assert html =~ "some updated title" + end + end +end diff --git a/test/support/fixtures/ledger_fixtures.ex b/test/support/fixtures/ledger_fixtures.ex new file mode 100644 index 0000000..cec7d60 --- /dev/null +++ b/test/support/fixtures/ledger_fixtures.ex @@ -0,0 +1,25 @@ +defmodule Dinge.LedgerFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Dinge.Ledger` context. + """ + + @doc """ + Generate a ding. + """ + def ding_fixture(attrs \\ %{}) do + {:ok, ding} = + attrs + |> Enum.into(%{ + count: 42, + ding_type: :countdown, + status: :active, + target_count: 42, + target_type: :default, + title: "some title" + }) + |> Dinge.Ledger.create_ding() + + ding + end +end