From 90414e6b934cc90eadc87943276b2dc122803fae Mon Sep 17 00:00:00 2001 From: Daniel Savory Date: Thu, 9 Jan 2025 21:43:05 +1100 Subject: [PATCH] initial book review schema --- lib/wild/book_reads.ex | 104 ++++++++++++++++ lib/wild/book_reads/book_read.ex | 24 ++++ lib/wild/books/author.ex | 21 ++++ lib/wild/books/book.ex | 20 ++++ .../live/book_read_live/form_component.ex | 86 +++++++++++++ lib/wild_web/live/book_read_live/index.ex | 47 ++++++++ .../live/book_read_live/index.html.heex | 45 +++++++ lib/wild_web/live/book_read_live/show.ex | 21 ++++ .../live/book_read_live/show.html.heex | 30 +++++ .../20250109091315_create_authors.exs | 15 +++ .../20250109091420_create_books.exs | 16 +++ .../20250109103126_create_book_reads.exs | 20 ++++ test/support/fixtures/book_reads_fixtures.ex | 24 ++++ test/wild/book_reads_test.exs | 67 +++++++++++ test/wild_web/live/book_read_live_test.exs | 113 ++++++++++++++++++ 15 files changed, 653 insertions(+) create mode 100644 lib/wild/book_reads.ex create mode 100644 lib/wild/book_reads/book_read.ex create mode 100644 lib/wild/books/author.ex create mode 100644 lib/wild/books/book.ex create mode 100644 lib/wild_web/live/book_read_live/form_component.ex create mode 100644 lib/wild_web/live/book_read_live/index.ex create mode 100644 lib/wild_web/live/book_read_live/index.html.heex create mode 100644 lib/wild_web/live/book_read_live/show.ex create mode 100644 lib/wild_web/live/book_read_live/show.html.heex create mode 100644 priv/repo/migrations/20250109091315_create_authors.exs create mode 100644 priv/repo/migrations/20250109091420_create_books.exs create mode 100644 priv/repo/migrations/20250109103126_create_book_reads.exs create mode 100644 test/support/fixtures/book_reads_fixtures.ex create mode 100644 test/wild/book_reads_test.exs create mode 100644 test/wild_web/live/book_read_live_test.exs diff --git a/lib/wild/book_reads.ex b/lib/wild/book_reads.ex new file mode 100644 index 0000000..aba8b30 --- /dev/null +++ b/lib/wild/book_reads.ex @@ -0,0 +1,104 @@ +defmodule Wild.BookReads do + @moduledoc """ + The BookReads context. + """ + + import Ecto.Query, warn: false + alias Wild.Repo + + alias Wild.BookReads.BookRead + + @doc """ + Returns the list of book_reads. + + ## Examples + + iex> list_book_reads() + [%BookRead{}, ...] + + """ + def list_book_reads do + Repo.all(BookRead) + end + + @doc """ + Gets a single book_read. + + Raises `Ecto.NoResultsError` if the Book read does not exist. + + ## Examples + + iex> get_book_read!(123) + %BookRead{} + + iex> get_book_read!(456) + ** (Ecto.NoResultsError) + + """ + def get_book_read!(id), do: Repo.get!(BookRead, id) + + @doc """ + Creates a book_read. + + ## Examples + + iex> create_book_read(%{field: value}) + {:ok, %BookRead{}} + + iex> create_book_read(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_book_read(attrs \\ %{}) do + %BookRead{} + |> BookRead.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a book_read. + + ## Examples + + iex> update_book_read(book_read, %{field: new_value}) + {:ok, %BookRead{}} + + iex> update_book_read(book_read, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_book_read(%BookRead{} = book_read, attrs) do + book_read + |> BookRead.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a book_read. + + ## Examples + + iex> delete_book_read(book_read) + {:ok, %BookRead{}} + + iex> delete_book_read(book_read) + {:error, %Ecto.Changeset{}} + + """ + def delete_book_read(%BookRead{} = book_read) do + Repo.delete(book_read) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking book_read changes. + + ## Examples + + iex> change_book_read(book_read) + %Ecto.Changeset{data: %BookRead{}} + + """ + def change_book_read(%BookRead{} = book_read, attrs \\ %{}) do + BookRead.changeset(book_read, attrs) + end +end diff --git a/lib/wild/book_reads/book_read.ex b/lib/wild/book_reads/book_read.ex new file mode 100644 index 0000000..6fe07f8 --- /dev/null +++ b/lib/wild/book_reads/book_read.ex @@ -0,0 +1,24 @@ +defmodule Wild.BookReads.BookRead do + use Ecto.Schema + import Ecto.Changeset + + @primary_key false + schema "book_reads" do + field :progress, :integer + field :rating, :integer + field :thoughts, :string + field :read_start, :utc_datetime + field :read_finish, :utc_datetime + field :user_id, :id, primary_key: true + field :book_id, :id, primary_key: true + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(book_read, attrs) do + book_read + |> cast(attrs, [:rating, :thoughts, :read_start, :read_finish, :progress]) + |> validate_required([:rating, :thoughts, :read_start, :read_finish, :progress]) + end +end diff --git a/lib/wild/books/author.ex b/lib/wild/books/author.ex new file mode 100644 index 0000000..f327f85 --- /dev/null +++ b/lib/wild/books/author.ex @@ -0,0 +1,21 @@ +defmodule Wild.Books.Author do + use Ecto.Schema + import Ecto.Changeset + + schema "authors" do + field :name, :string + field :country, :string + field :bio, :string + field :year_born, :string + field :year_died, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(author, attrs) do + author + |> cast(attrs, [:name, :country, :bio, :year_born, :year_died]) + |> validate_required([:name, :country, :bio, :year_born, :year_died]) + end +end diff --git a/lib/wild/books/book.ex b/lib/wild/books/book.ex new file mode 100644 index 0000000..c2ba8fb --- /dev/null +++ b/lib/wild/books/book.ex @@ -0,0 +1,20 @@ +defmodule Wild.Books.Book do + use Ecto.Schema + import Ecto.Changeset + + schema "books" do + field :title, :string + field :blurb, :string + field :publication_year, :string + field :author_id, :id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(book, attrs) do + book + |> cast(attrs, [:title, :blurb, :publication_year]) + |> validate_required([:title, :blurb, :publication_year]) + end +end diff --git a/lib/wild_web/live/book_read_live/form_component.ex b/lib/wild_web/live/book_read_live/form_component.ex new file mode 100644 index 0000000..9d7be8c --- /dev/null +++ b/lib/wild_web/live/book_read_live/form_component.ex @@ -0,0 +1,86 @@ +defmodule WildWeb.BookReadLive.FormComponent do + use WildWeb, :live_component + + alias Wild.BookReads + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + {@title} + <:subtitle>Use this form to manage book_read records in your database. + + + <.simple_form + for={@form} + id="book_read-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:rating]} type="number" label="Rating" /> + <.input field={@form[:thoughts]} type="text" label="Thoughts" /> + <.input field={@form[:read_start]} type="datetime-local" label="Read start" /> + <.input field={@form[:read_finish]} type="datetime-local" label="Read finish" /> + <.input field={@form[:progress]} type="number" label="Progress" /> + <:actions> + <.button phx-disable-with="Saving...">Save Book read + + +
+ """ + end + + @impl true + def update(%{book_read: book_read} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(BookReads.change_book_read(book_read)) + end)} + end + + @impl true + def handle_event("validate", %{"book_read" => book_read_params}, socket) do + changeset = BookReads.change_book_read(socket.assigns.book_read, book_read_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"book_read" => book_read_params}, socket) do + save_book_read(socket, socket.assigns.action, book_read_params) + end + + defp save_book_read(socket, :edit, book_read_params) do + case BookReads.update_book_read(socket.assigns.book_read, book_read_params) do + {:ok, book_read} -> + notify_parent({:saved, book_read}) + + {:noreply, + socket + |> put_flash(:info, "Book read updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_book_read(socket, :new, book_read_params) do + case BookReads.create_book_read(book_read_params) do + {:ok, book_read} -> + notify_parent({:saved, book_read}) + + {:noreply, + socket + |> put_flash(:info, "Book read created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) +end diff --git a/lib/wild_web/live/book_read_live/index.ex b/lib/wild_web/live/book_read_live/index.ex new file mode 100644 index 0000000..dccc6d9 --- /dev/null +++ b/lib/wild_web/live/book_read_live/index.ex @@ -0,0 +1,47 @@ +defmodule WildWeb.BookReadLive.Index do + use WildWeb, :live_view + + alias Wild.BookReads + alias Wild.BookReads.BookRead + + @impl true + def mount(_params, _session, socket) do + {:ok, stream(socket, :book_reads, BookReads.list_book_reads())} + 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 Book read") + |> assign(:book_read, BookReads.get_book_read!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Book read") + |> assign(:book_read, %BookRead{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Book reads") + |> assign(:book_read, nil) + end + + @impl true + def handle_info({WildWeb.BookReadLive.FormComponent, {:saved, book_read}}, socket) do + {:noreply, stream_insert(socket, :book_reads, book_read)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + book_read = BookReads.get_book_read!(id) + {:ok, _} = BookReads.delete_book_read(book_read) + + {:noreply, stream_delete(socket, :book_reads, book_read)} + end +end diff --git a/lib/wild_web/live/book_read_live/index.html.heex b/lib/wild_web/live/book_read_live/index.html.heex new file mode 100644 index 0000000..6d9c3d5 --- /dev/null +++ b/lib/wild_web/live/book_read_live/index.html.heex @@ -0,0 +1,45 @@ +<.header> + Listing Book reads + <:actions> + <.link patch={~p"/book_reads/new"}> + <.button>New Book read + + + + +<.table + id="book_reads" + rows={@streams.book_reads} + row_click={fn {_id, book_read} -> JS.navigate(~p"/book_reads/#{book_read}") end} +> + <:col :let={{_id, book_read}} label="Rating">{book_read.rating} + <:col :let={{_id, book_read}} label="Thoughts">{book_read.thoughts} + <:col :let={{_id, book_read}} label="Read start">{book_read.read_start} + <:col :let={{_id, book_read}} label="Read finish">{book_read.read_finish} + <:col :let={{_id, book_read}} label="Progress">{book_read.progress} + <:action :let={{_id, book_read}}> +
+ <.link navigate={~p"/book_reads/#{book_read}"}>Show +
+ <.link patch={~p"/book_reads/#{book_read}/edit"}>Edit + + <:action :let={{id, book_read}}> + <.link + phx-click={JS.push("delete", value: %{id: book_read.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + + +<.modal :if={@live_action in [:new, :edit]} id="book_read-modal" show on_cancel={JS.patch(~p"/book_reads")}> + <.live_component + module={WildWeb.BookReadLive.FormComponent} + id={@book_read.id || :new} + title={@page_title} + action={@live_action} + book_read={@book_read} + patch={~p"/book_reads"} + /> + diff --git a/lib/wild_web/live/book_read_live/show.ex b/lib/wild_web/live/book_read_live/show.ex new file mode 100644 index 0000000..ed329c8 --- /dev/null +++ b/lib/wild_web/live/book_read_live/show.ex @@ -0,0 +1,21 @@ +defmodule WildWeb.BookReadLive.Show do + use WildWeb, :live_view + + alias Wild.BookReads + + @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(:book_read, BookReads.get_book_read!(id))} + end + + defp page_title(:show), do: "Show Book read" + defp page_title(:edit), do: "Edit Book read" +end diff --git a/lib/wild_web/live/book_read_live/show.html.heex b/lib/wild_web/live/book_read_live/show.html.heex new file mode 100644 index 0000000..af74354 --- /dev/null +++ b/lib/wild_web/live/book_read_live/show.html.heex @@ -0,0 +1,30 @@ +<.header> + Book read {@book_read.id} + <:subtitle>This is a book_read record from your database. + <:actions> + <.link patch={~p"/book_reads/#{@book_read}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit book_read + + + + +<.list> + <:item title="Rating">{@book_read.rating} + <:item title="Thoughts">{@book_read.thoughts} + <:item title="Read start">{@book_read.read_start} + <:item title="Read finish">{@book_read.read_finish} + <:item title="Progress">{@book_read.progress} + + +<.back navigate={~p"/book_reads"}>Back to book_reads + +<.modal :if={@live_action == :edit} id="book_read-modal" show on_cancel={JS.patch(~p"/book_reads/#{@book_read}")}> + <.live_component + module={WildWeb.BookReadLive.FormComponent} + id={@book_read.id} + title={@page_title} + action={@live_action} + book_read={@book_read} + patch={~p"/book_reads/#{@book_read}"} + /> + diff --git a/priv/repo/migrations/20250109091315_create_authors.exs b/priv/repo/migrations/20250109091315_create_authors.exs new file mode 100644 index 0000000..62bd4bb --- /dev/null +++ b/priv/repo/migrations/20250109091315_create_authors.exs @@ -0,0 +1,15 @@ +defmodule Wild.Repo.Migrations.CreateAuthors do + use Ecto.Migration + + def change do + create table(:authors) do + add :name, :string + add :country, :string + add :bio, :string + add :year_born, :string + add :year_died, :string + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20250109091420_create_books.exs b/priv/repo/migrations/20250109091420_create_books.exs new file mode 100644 index 0000000..72b3827 --- /dev/null +++ b/priv/repo/migrations/20250109091420_create_books.exs @@ -0,0 +1,16 @@ +defmodule Wild.Repo.Migrations.CreateBooks do + use Ecto.Migration + + def change do + create table(:books) do + add :title, :string + add :blurb, :string + add :publication_year, :string + add :author_id, references(:authors, on_delete: :nothing) + + timestamps(type: :utc_datetime) + end + + create index(:books, [:author_id]) + end +end diff --git a/priv/repo/migrations/20250109103126_create_book_reads.exs b/priv/repo/migrations/20250109103126_create_book_reads.exs new file mode 100644 index 0000000..12525f1 --- /dev/null +++ b/priv/repo/migrations/20250109103126_create_book_reads.exs @@ -0,0 +1,20 @@ +defmodule Wild.Repo.Migrations.CreateBookReads do + use Ecto.Migration + + def change do + create table(:book_reads) do + add :rating, :integer + add :thoughts, :string + add :read_start, :utc_datetime + add :read_finish, :utc_datetime + add :progress, :integer + add :user_id, references(:users, on_delete: :nothing) + add :book_id, references(:books, on_delete: :nothing) + + timestamps(type: :utc_datetime) + end + + create index(:book_reads, [:user_id]) + create index(:book_reads, [:book_id]) + end +end diff --git a/test/support/fixtures/book_reads_fixtures.ex b/test/support/fixtures/book_reads_fixtures.ex new file mode 100644 index 0000000..3ea7dc8 --- /dev/null +++ b/test/support/fixtures/book_reads_fixtures.ex @@ -0,0 +1,24 @@ +defmodule Wild.BookReadsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Wild.BookReads` context. + """ + + @doc """ + Generate a book_read. + """ + def book_read_fixture(attrs \\ %{}) do + {:ok, book_read} = + attrs + |> Enum.into(%{ + progress: 42, + rating: 42, + read_finish: ~U[2025-01-08 10:31:00Z], + read_start: ~U[2025-01-08 10:31:00Z], + thoughts: "some thoughts" + }) + |> Wild.BookReads.create_book_read() + + book_read + end +end diff --git a/test/wild/book_reads_test.exs b/test/wild/book_reads_test.exs new file mode 100644 index 0000000..e054aa0 --- /dev/null +++ b/test/wild/book_reads_test.exs @@ -0,0 +1,67 @@ +defmodule Wild.BookReadsTest do + use Wild.DataCase + + alias Wild.BookReads + + describe "book_reads" do + alias Wild.BookReads.BookRead + + import Wild.BookReadsFixtures + + @invalid_attrs %{progress: nil, rating: nil, thoughts: nil, read_start: nil, read_finish: nil} + + test "list_book_reads/0 returns all book_reads" do + book_read = book_read_fixture() + assert BookReads.list_book_reads() == [book_read] + end + + test "get_book_read!/1 returns the book_read with given id" do + book_read = book_read_fixture() + assert BookReads.get_book_read!(book_read.id) == book_read + end + + test "create_book_read/1 with valid data creates a book_read" do + valid_attrs = %{progress: 42, rating: 42, thoughts: "some thoughts", read_start: ~U[2025-01-08 10:31:00Z], read_finish: ~U[2025-01-08 10:31:00Z]} + + assert {:ok, %BookRead{} = book_read} = BookReads.create_book_read(valid_attrs) + assert book_read.progress == 42 + assert book_read.rating == 42 + assert book_read.thoughts == "some thoughts" + assert book_read.read_start == ~U[2025-01-08 10:31:00Z] + assert book_read.read_finish == ~U[2025-01-08 10:31:00Z] + end + + test "create_book_read/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = BookReads.create_book_read(@invalid_attrs) + end + + test "update_book_read/2 with valid data updates the book_read" do + book_read = book_read_fixture() + update_attrs = %{progress: 43, rating: 43, thoughts: "some updated thoughts", read_start: ~U[2025-01-09 10:31:00Z], read_finish: ~U[2025-01-09 10:31:00Z]} + + assert {:ok, %BookRead{} = book_read} = BookReads.update_book_read(book_read, update_attrs) + assert book_read.progress == 43 + assert book_read.rating == 43 + assert book_read.thoughts == "some updated thoughts" + assert book_read.read_start == ~U[2025-01-09 10:31:00Z] + assert book_read.read_finish == ~U[2025-01-09 10:31:00Z] + end + + test "update_book_read/2 with invalid data returns error changeset" do + book_read = book_read_fixture() + assert {:error, %Ecto.Changeset{}} = BookReads.update_book_read(book_read, @invalid_attrs) + assert book_read == BookReads.get_book_read!(book_read.id) + end + + test "delete_book_read/1 deletes the book_read" do + book_read = book_read_fixture() + assert {:ok, %BookRead{}} = BookReads.delete_book_read(book_read) + assert_raise Ecto.NoResultsError, fn -> BookReads.get_book_read!(book_read.id) end + end + + test "change_book_read/1 returns a book_read changeset" do + book_read = book_read_fixture() + assert %Ecto.Changeset{} = BookReads.change_book_read(book_read) + end + end +end diff --git a/test/wild_web/live/book_read_live_test.exs b/test/wild_web/live/book_read_live_test.exs new file mode 100644 index 0000000..1429447 --- /dev/null +++ b/test/wild_web/live/book_read_live_test.exs @@ -0,0 +1,113 @@ +defmodule WildWeb.BookReadLiveTest do + use WildWeb.ConnCase + + import Phoenix.LiveViewTest + import Wild.BookReadsFixtures + + @create_attrs %{progress: 42, rating: 42, thoughts: "some thoughts", read_start: "2025-01-08T10:31:00Z", read_finish: "2025-01-08T10:31:00Z"} + @update_attrs %{progress: 43, rating: 43, thoughts: "some updated thoughts", read_start: "2025-01-09T10:31:00Z", read_finish: "2025-01-09T10:31:00Z"} + @invalid_attrs %{progress: nil, rating: nil, thoughts: nil, read_start: nil, read_finish: nil} + + defp create_book_read(_) do + book_read = book_read_fixture() + %{book_read: book_read} + end + + describe "Index" do + setup [:create_book_read] + + test "lists all book_reads", %{conn: conn, book_read: book_read} do + {:ok, _index_live, html} = live(conn, ~p"/book_reads") + + assert html =~ "Listing Book reads" + assert html =~ book_read.thoughts + end + + test "saves new book_read", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/book_reads") + + assert index_live |> element("a", "New Book read") |> render_click() =~ + "New Book read" + + assert_patch(index_live, ~p"/book_reads/new") + + assert index_live + |> form("#book_read-form", book_read: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#book_read-form", book_read: @create_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/book_reads") + + html = render(index_live) + assert html =~ "Book read created successfully" + assert html =~ "some thoughts" + end + + test "updates book_read in listing", %{conn: conn, book_read: book_read} do + {:ok, index_live, _html} = live(conn, ~p"/book_reads") + + assert index_live |> element("#book_reads-#{book_read.id} a", "Edit") |> render_click() =~ + "Edit Book read" + + assert_patch(index_live, ~p"/book_reads/#{book_read}/edit") + + assert index_live + |> form("#book_read-form", book_read: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert index_live + |> form("#book_read-form", book_read: @update_attrs) + |> render_submit() + + assert_patch(index_live, ~p"/book_reads") + + html = render(index_live) + assert html =~ "Book read updated successfully" + assert html =~ "some updated thoughts" + end + + test "deletes book_read in listing", %{conn: conn, book_read: book_read} do + {:ok, index_live, _html} = live(conn, ~p"/book_reads") + + assert index_live |> element("#book_reads-#{book_read.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#book_reads-#{book_read.id}") + end + end + + describe "Show" do + setup [:create_book_read] + + test "displays book_read", %{conn: conn, book_read: book_read} do + {:ok, _show_live, html} = live(conn, ~p"/book_reads/#{book_read}") + + assert html =~ "Show Book read" + assert html =~ book_read.thoughts + end + + test "updates book_read within modal", %{conn: conn, book_read: book_read} do + {:ok, show_live, _html} = live(conn, ~p"/book_reads/#{book_read}") + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Book read" + + assert_patch(show_live, ~p"/book_reads/#{book_read}/show/edit") + + assert show_live + |> form("#book_read-form", book_read: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert show_live + |> form("#book_read-form", book_read: @update_attrs) + |> render_submit() + + assert_patch(show_live, ~p"/book_reads/#{book_read}") + + html = render(show_live) + assert html =~ "Book read updated successfully" + assert html =~ "some updated thoughts" + end + end +end