diff --git a/lib/chirp/timeline.ex b/lib/chirp/timeline.ex new file mode 100644 index 0000000..a9c2d76 --- /dev/null +++ b/lib/chirp/timeline.ex @@ -0,0 +1,116 @@ +defmodule Chirp.Timeline do + @moduledoc """ + The Timeline context. + """ + + import Ecto.Query, warn: false + alias Chirp.Repo + + alias Chirp.Timeline.Post + + @doc """ + Returns the list of posts. + + ## Examples + + iex> list_posts() + [%Post{}, ...] + + """ + def list_posts do + Repo.all(from p in Post, order_by: [desc: p.id]) + end + + @doc """ + Gets a single post. + + Raises `Ecto.NoResultsError` if the Post does not exist. + + ## Examples + + iex> get_post!(123) + %Post{} + + iex> get_post!(456) + ** (Ecto.NoResultsError) + + """ + def get_post!(id), do: Repo.get!(Post, id) + + @doc """ + Creates a post. + + ## Examples + + iex> create_post(%{field: value}) + {:ok, %Post{}} + + iex> create_post(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_post(attrs \\ %{}) do + %Post{} + |> Post.changeset(attrs) + |> Repo.insert() + |> broadcast(:post_created) + end + + @doc """ + Updates a post. + + ## Examples + + iex> update_post(post, %{field: new_value}) + {:ok, %Post{}} + + iex> update_post(post, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_post(%Post{} = post, attrs) do + post + |> Post.changeset(attrs) + |> Repo.update() + |> broadcast(:post_updated) + end + + @doc """ + Deletes a post. + + ## Examples + + iex> delete_post(post) + {:ok, %Post{}} + + iex> delete_post(post) + {:error, %Ecto.Changeset{}} + + """ + def delete_post(%Post{} = post) do + Repo.delete(post) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking post changes. + + ## Examples + + iex> change_post(post) + %Ecto.Changeset{data: %Post{}} + + """ + def change_post(%Post{} = post, attrs \\ %{}) do + Post.changeset(post, attrs) + end + + def subscribe do + Phoenix.PubSub.subscribe(Chirp.PubSub,"posts") + end + + defp broadcast({:error, _reason} = error, _event), do: error + defp broadcast({:ok,post}, event) do + Phoenix.PubSub.broadcast(Chirp.PubSub, "posts", {event,post}) + {:ok, post} + end +end diff --git a/lib/chirp/timeline/post.ex b/lib/chirp/timeline/post.ex new file mode 100644 index 0000000..bd5fd39 --- /dev/null +++ b/lib/chirp/timeline/post.ex @@ -0,0 +1,22 @@ +defmodule Chirp.Timeline.Post do + use Ecto.Schema + import Ecto.Changeset + + schema "posts" do + field :body, :string + field :likes_count, :integer, default: 0 + field :reposts_count, :integer, default: 0 + field :username, :string, default: "julz" + + timestamps() + end + + @doc false + def changeset(post, attrs) do + post + |> cast(attrs, [:body]) + |> validate_required([:body]) + |> validate_length(:body, min: 2, max: 250) + + end +end diff --git a/lib/chirp_web/live/post_live/form_component.ex b/lib/chirp_web/live/post_live/form_component.ex new file mode 100644 index 0000000..f3d6f44 --- /dev/null +++ b/lib/chirp_web/live/post_live/form_component.ex @@ -0,0 +1,81 @@ +defmodule ChirpWeb.PostLive.FormComponent do + use ChirpWeb, :live_component + + alias Chirp.Timeline + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + <%= @title %> + <:subtitle>Use this form to manage post records in your database. + + + <.simple_form + :let={f} + for={@changeset} + id="post-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={{f, :body}} type="textarea" label="body" /> + <:actions> + <.button phx-disable-with="Saving...">Save Post + + +
+ """ + end + + @impl true + def update(%{post: post} = assigns, socket) do + changeset = Timeline.change_post(post) + + {:ok, + socket + |> assign(assigns) + |> assign(:changeset, changeset)} + end + + @impl true + def handle_event("validate", %{"post" => post_params}, socket) do + changeset = + socket.assigns.post + |> Timeline.change_post(post_params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :changeset, changeset)} + end + + def handle_event("save", %{"post" => post_params}, socket) do + save_post(socket, socket.assigns.action, post_params) + end + + defp save_post(socket, :edit, post_params) do + case Timeline.update_post(socket.assigns.post, post_params) do + {:ok, _post} -> + {:noreply, + socket + |> put_flash(:info, "Post updated successfully") + |> push_navigate(to: socket.assigns.navigate)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + defp save_post(socket, :new, post_params) do + case Timeline.create_post(post_params) do + {:ok, _post} -> + {:noreply, + socket + |> put_flash(:info, "Post created successfully") + |> push_navigate(to: socket.assigns.navigate)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end +end diff --git a/lib/chirp_web/live/post_live/index.ex b/lib/chirp_web/live/post_live/index.ex new file mode 100644 index 0000000..91fe702 --- /dev/null +++ b/lib/chirp_web/live/post_live/index.ex @@ -0,0 +1,61 @@ +defmodule ChirpWeb.PostLive.Index do + use ChirpWeb, :live_view + + alias Chirp.Timeline + alias Chirp.Timeline.Post + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Timeline.subscribe() + {:ok, assign(socket, :posts, list_posts()), temporary_assigns: [posts: []]} + 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 Post") + |> assign(:post, Timeline.get_post!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Post") + |> assign(:post, %Post{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Posts") + |> assign(:post, nil) + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + post = Timeline.get_post!(id) + {:ok, _} = Timeline.delete_post(post) + + {:noreply, assign(socket, :posts, list_posts())} + end + def handle_event("like", %{"id" => _id}, socket) do + {:noreply,socket} + end + def handle_event("repost", %{"id" => _id}, socket) do + {:noreply,socket} + end + + def handle_info({:post_created,post}, socket) do + {:noreply, update(socket, :posts, fn posts-> [post|posts] end)} + end + + def handle_info({:post_updated,post}, socket) do + {:noreply, update(socket, :posts, fn posts-> [post|posts] end)} + end + + defp list_posts do + Timeline.list_posts() + end +end diff --git a/lib/chirp_web/live/post_live/index.html.heex b/lib/chirp_web/live/post_live/index.html.heex new file mode 100644 index 0000000..4f604fd --- /dev/null +++ b/lib/chirp_web/live/post_live/index.html.heex @@ -0,0 +1,34 @@ +<.header> + Timeline + <:actions> + <.link patch={~p"/posts/new"}> + <.button>New Post + + + + + +
+ <%= for post <- @posts do %> + <.live_component + module={ChirpWeb.PostLive.PostComponent} + id={post.id} + post={post} + /> + <% end %> +
+ + + +<%= if @live_action in [:new, :edit] do %> + <.modal id="post-modal" show on_cancel={JS.navigate(~p"/posts")}> + <.live_component + module={ChirpWeb.PostLive.FormComponent} + id={@post.id || :new} + title={@page_title} + action={@live_action} + post={@post} + navigate={~p"/posts"} + /> + +<% end %> diff --git a/lib/chirp_web/live/post_live/post_component.ex b/lib/chirp_web/live/post_live/post_component.ex new file mode 100644 index 0000000..a2afa13 --- /dev/null +++ b/lib/chirp_web/live/post_live/post_component.ex @@ -0,0 +1,78 @@ +defmodule ChirpWeb.PostLive.PostComponent do + use ChirpWeb, :live_component + + @spec render(any) :: Phoenix.LiveView.Rendered.t() + def render(assigns) do + ~H""" +
+
+ +
+ + + +
<%= @post.username %>
+ +
+
+ +
+
<%= @post.body %>
+
+
+ <.link navigate={~p"/posts/#{@post}"} class="inline-flex items-center text-blue-600 hover:underline"> + Show + + + + + +
+ +
+ <.link phx-click="like" phx-value-id={@post.id}> + + + + + + <%= @post.likes_count %> + +
+ +
+ <.link phx-click="repost" phx-value-id={@post.id}> + + + + +
+ <%= @post.reposts_count %> +
+
+
+ <.link patch={~p"/posts/#{@post}/edit"}>Edit +
+
+ <.link phx-click={JS.push("delete", value: %{id: @post.id})} data-confirm="Are you sure?"> + Delete + +
+
+ + + +
+ + + + + + +
+
+
+ """ + end + +end diff --git a/lib/chirp_web/live/post_live/show.ex b/lib/chirp_web/live/post_live/show.ex new file mode 100644 index 0000000..f69695b --- /dev/null +++ b/lib/chirp_web/live/post_live/show.ex @@ -0,0 +1,21 @@ +defmodule ChirpWeb.PostLive.Show do + use ChirpWeb, :live_view + + alias Chirp.Timeline + + @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(:post, Timeline.get_post!(id))} + end + + defp page_title(:show), do: "Show Post" + defp page_title(:edit), do: "Edit Post" +end diff --git a/lib/chirp_web/live/post_live/show.html.heex b/lib/chirp_web/live/post_live/show.html.heex new file mode 100644 index 0000000..eeb8a9e --- /dev/null +++ b/lib/chirp_web/live/post_live/show.html.heex @@ -0,0 +1,32 @@ +<.header> + Post <%= @post.id %> + <:subtitle>This is a post record from your database. + <:actions> + <.link patch={~p"/posts/#{@post}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit post + + + + +<.list> + <:item title="Username"><%= @post.username %> + <:item title="Body"><%= @post.body %> + <:item title="Likes count"><%= @post.likes_count %> + <:item title="Reposts count"><%= @post.reposts_count %> + + +<.back navigate={~p"/posts"}>Back to posts + +<%= if @live_action in [:edit] do %> + <.modal id="post-modal" show on_cancel={JS.patch(~p"/posts/#{@post}")}> + <.live_component + module={ChirpWeb.PostLive.FormComponent} + id={@post.id} + title={@page_title} + action={@live_action} + post={@post} + navigate={~p"/posts/#{@post}"} + /> + +<% end %> + diff --git a/lib/chirp_web/router.ex b/lib/chirp_web/router.ex index f18aa43..80ff7b0 100644 --- a/lib/chirp_web/router.ex +++ b/lib/chirp_web/router.ex @@ -18,6 +18,15 @@ defmodule ChirpWeb.Router do pipe_through :browser get "/", PageController, :index + + live "/posts", PostLive.Index, :index + live "/posts/new", PostLive.Index, :new + live "/posts/:id/edit", PostLive.Index, :edit + + live "/posts/:id", PostLive.Show, :show + live "/posts/:id/show/edit", PostLive.Show, :edit + + end # Other scopes may use custom stacks. diff --git a/test/chirp/timeline_test.exs b/test/chirp/timeline_test.exs new file mode 100644 index 0000000..df96977 --- /dev/null +++ b/test/chirp/timeline_test.exs @@ -0,0 +1,65 @@ +defmodule Chirp.TimelineTest do + use Chirp.DataCase + + alias Chirp.Timeline + + describe "posts" do + alias Chirp.Timeline.Post + + import Chirp.TimelineFixtures + + @invalid_attrs %{body: nil, likes_count: nil, reposts_count: nil, username: nil} + + test "list_posts/0 returns all posts" do + post = post_fixture() + assert Timeline.list_posts() == [post] + end + + test "get_post!/1 returns the post with given id" do + post = post_fixture() + assert Timeline.get_post!(post.id) == post + end + + test "create_post/1 with valid data creates a post" do + valid_attrs = %{body: "some body", likes_count: 42, reposts_count: 42, username: "some username"} + + assert {:ok, %Post{} = post} = Timeline.create_post(valid_attrs) + assert post.body == "some body" + assert post.likes_count == 42 + assert post.reposts_count == 42 + assert post.username == "some username" + end + + test "create_post/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Timeline.create_post(@invalid_attrs) + end + + test "update_post/2 with valid data updates the post" do + post = post_fixture() + update_attrs = %{body: "some updated body", likes_count: 43, reposts_count: 43, username: "some updated username"} + + assert {:ok, %Post{} = post} = Timeline.update_post(post, update_attrs) + assert post.body == "some updated body" + assert post.likes_count == 43 + assert post.reposts_count == 43 + assert post.username == "some updated username" + end + + test "update_post/2 with invalid data returns error changeset" do + post = post_fixture() + assert {:error, %Ecto.Changeset{}} = Timeline.update_post(post, @invalid_attrs) + assert post == Timeline.get_post!(post.id) + end + + test "delete_post/1 deletes the post" do + post = post_fixture() + assert {:ok, %Post{}} = Timeline.delete_post(post) + assert_raise Ecto.NoResultsError, fn -> Timeline.get_post!(post.id) end + end + + test "change_post/1 returns a post changeset" do + post = post_fixture() + assert %Ecto.Changeset{} = Timeline.change_post(post) + end + end +end diff --git a/test/chirp_web/live/post_live_test.exs b/test/chirp_web/live/post_live_test.exs new file mode 100644 index 0000000..f956156 --- /dev/null +++ b/test/chirp_web/live/post_live_test.exs @@ -0,0 +1,110 @@ +defmodule ChirpWeb.PostLiveTest do + use ChirpWeb.ConnCase + + import Phoenix.LiveViewTest + import Chirp.TimelineFixtures + + @create_attrs %{body: "some body", likes_count: 42, reposts_count: 42, username: "some username"} + @update_attrs %{body: "some updated body", likes_count: 43, reposts_count: 43, username: "some updated username"} + @invalid_attrs %{body: nil, likes_count: nil, reposts_count: nil, username: nil} + + defp create_post(_) do + post = post_fixture() + %{post: post} + end + + describe "Index" do + setup [:create_post] + + test "lists all posts", %{conn: conn, post: post} do + {:ok, _index_live, html} = live(conn, ~p"/posts") + + assert html =~ "Listing Posts" + assert html =~ post.body + end + + test "saves new post", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/posts") + + assert index_live |> element("a", "New Post") |> render_click() =~ + "New Post" + + assert_patch(index_live, ~p"/posts/new") + + assert index_live + |> form("#post-form", post: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#post-form", post: @create_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/posts") + + assert html =~ "Post created successfully" + assert html =~ "some body" + end + + test "updates post in listing", %{conn: conn, post: post} do + {:ok, index_live, _html} = live(conn, ~p"/posts") + + assert index_live |> element("#posts-#{post.id} a", "Edit") |> render_click() =~ + "Edit Post" + + assert_patch(index_live, ~p"/posts/#{post}/edit") + + assert index_live + |> form("#post-form", post: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#post-form", post: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/posts") + + assert html =~ "Post updated successfully" + assert html =~ "some updated body" + end + + test "deletes post in listing", %{conn: conn, post: post} do + {:ok, index_live, _html} = live(conn, ~p"/posts") + + assert index_live |> element("#posts-#{post.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#post-#{post.id}") + end + end + + describe "Show" do + setup [:create_post] + + test "displays post", %{conn: conn, post: post} do + {:ok, _show_live, html} = live(conn, ~p"/posts/#{post}") + + assert html =~ "Show Post" + assert html =~ post.body + end + + test "updates post within modal", %{conn: conn, post: post} do + {:ok, show_live, _html} = live(conn, ~p"/posts/#{post}") + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Post" + + assert_patch(show_live, ~p"/posts/#{post}/show/edit") + + assert show_live + |> form("#post-form", post: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + show_live + |> form("#post-form", post: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/posts/#{post}") + + assert html =~ "Post updated successfully" + assert html =~ "some updated body" + end + end +end diff --git a/test/support/fixtures/timeline_fixtures.ex b/test/support/fixtures/timeline_fixtures.ex new file mode 100644 index 0000000..38ecaf5 --- /dev/null +++ b/test/support/fixtures/timeline_fixtures.ex @@ -0,0 +1,23 @@ +defmodule Chirp.TimelineFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Chirp.Timeline` context. + """ + + @doc """ + Generate a post. + """ + def post_fixture(attrs \\ %{}) do + {:ok, post} = + attrs + |> Enum.into(%{ + body: "some body", + likes_count: 42, + reposts_count: 42, + username: "some username" + }) + |> Chirp.Timeline.create_post() + + post + end +end