diff --git a/lib/memex/notes.ex b/lib/memex/notes.ex new file mode 100644 index 0000000..6670bb1 --- /dev/null +++ b/lib/memex/notes.ex @@ -0,0 +1,104 @@ +defmodule Memex.Notes do + @moduledoc """ + The Notes context. + """ + + import Ecto.Query, warn: false + alias Memex.Repo + + alias Memex.Notes.Note + + @doc """ + Returns the list of notes. + + ## Examples + + iex> list_notes() + [%Note{}, ...] + + """ + def list_notes do + Repo.all(Note) + end + + @doc """ + Gets a single note. + + Raises `Ecto.NoResultsError` if the Note does not exist. + + ## Examples + + iex> get_note!(123) + %Note{} + + iex> get_note!(456) + ** (Ecto.NoResultsError) + + """ + def get_note!(id), do: Repo.get!(Note, id) + + @doc """ + Creates a note. + + ## Examples + + iex> create_note(%{field: value}) + {:ok, %Note{}} + + iex> create_note(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_note(attrs \\ %{}) do + %Note{} + |> Note.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a note. + + ## Examples + + iex> update_note(note, %{field: new_value}) + {:ok, %Note{}} + + iex> update_note(note, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_note(%Note{} = note, attrs) do + note + |> Note.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a note. + + ## Examples + + iex> delete_note(note) + {:ok, %Note{}} + + iex> delete_note(note) + {:error, %Ecto.Changeset{}} + + """ + def delete_note(%Note{} = note) do + Repo.delete(note) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking note changes. + + ## Examples + + iex> change_note(note) + %Ecto.Changeset{data: %Note{}} + + """ + def change_note(%Note{} = note, attrs \\ %{}) do + Note.changeset(note, attrs) + end +end diff --git a/lib/memex/notes/note.ex b/lib/memex/notes/note.ex new file mode 100644 index 0000000..1175d5a --- /dev/null +++ b/lib/memex/notes/note.ex @@ -0,0 +1,22 @@ +defmodule Memex.Notes.Note do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "notes" do + field :content, :string + field :tag, {:array, :string} + field :title, :string + field :visibility, Ecto.Enum, values: [:public, :private, :unlisted] + + timestamps() + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [:title, :content, :tag, :visibility]) + |> validate_required([:title, :content, :tag, :visibility]) + end +end diff --git a/lib/memex_web.ex b/lib/memex_web.ex index 8ffff33..1eacaf4 100644 --- a/lib/memex_web.ex +++ b/lib/memex_web.ex @@ -92,6 +92,7 @@ defmodule MemexWeb do # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) import Phoenix.LiveView.Helpers + import MemexWeb.LiveHelpers # Import basic rendering functionality (render, render_layout, etc) import Phoenix.View diff --git a/lib/memex_web/live/note_live/form_component.ex b/lib/memex_web/live/note_live/form_component.ex new file mode 100644 index 0000000..a3deff8 --- /dev/null +++ b/lib/memex_web/live/note_live/form_component.ex @@ -0,0 +1,55 @@ +defmodule MemexWeb.NoteLive.FormComponent do + use MemexWeb, :live_component + + alias Memex.Notes + + @impl true + def update(%{note: note} = assigns, socket) do + changeset = Notes.change_note(note) + + {:ok, + socket + |> assign(assigns) + |> assign(:changeset, changeset)} + end + + @impl true + def handle_event("validate", %{"note" => note_params}, socket) do + changeset = + socket.assigns.note + |> Notes.change_note(note_params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :changeset, changeset)} + end + + def handle_event("save", %{"note" => note_params}, socket) do + save_note(socket, socket.assigns.action, note_params) + end + + defp save_note(socket, :edit, note_params) do + case Notes.update_note(socket.assigns.note, note_params) do + {:ok, _note} -> + {:noreply, + socket + |> put_flash(:info, "Note updated successfully") + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + defp save_note(socket, :new, note_params) do + case Notes.create_note(note_params) do + {:ok, _note} -> + {:noreply, + socket + |> put_flash(:info, "Note created successfully") + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, changeset: changeset)} + end + end +end diff --git a/lib/memex_web/live/note_live/form_component.html.heex b/lib/memex_web/live/note_live/form_component.html.heex new file mode 100644 index 0000000..860963e --- /dev/null +++ b/lib/memex_web/live/note_live/form_component.html.heex @@ -0,0 +1,32 @@ +
+

<%= @title %>

+ + <.form + let={f} + for={@changeset} + id="note-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save"> + + <%= label f, :title %> + <%= text_input f, :title %> + <%= error_tag f, :title %> + + <%= label f, :content %> + <%= textarea f, :content %> + <%= error_tag f, :content %> + + <%= label f, :tag %> + <%= multiple_select f, :tag, ["Option 1": "option1", "Option 2": "option2"] %> + <%= error_tag f, :tag %> + + <%= label f, :visibility %> + <%= select f, :visibility, Ecto.Enum.values(Memex.Notes.Note, :visibility), prompt: "Choose a value" %> + <%= error_tag f, :visibility %> + +
+ <%= submit "Save", phx_disable_with: "Saving..." %> +
+ +
diff --git a/lib/memex_web/live/note_live/index.ex b/lib/memex_web/live/note_live/index.ex new file mode 100644 index 0000000..f0e8fb1 --- /dev/null +++ b/lib/memex_web/live/note_live/index.ex @@ -0,0 +1,46 @@ +defmodule MemexWeb.NoteLive.Index do + use MemexWeb, :live_view + + alias Memex.Notes + alias Memex.Notes.Note + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :notes, list_notes())} + 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 Note") + |> assign(:note, Notes.get_note!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Note") + |> assign(:note, %Note{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Notes") + |> assign(:note, nil) + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + note = Notes.get_note!(id) + {:ok, _} = Notes.delete_note(note) + + {:noreply, assign(socket, :notes, list_notes())} + end + + defp list_notes do + Notes.list_notes() + end +end diff --git a/lib/memex_web/live/note_live/index.html.heex b/lib/memex_web/live/note_live/index.html.heex new file mode 100644 index 0000000..05fb582 --- /dev/null +++ b/lib/memex_web/live/note_live/index.html.heex @@ -0,0 +1,45 @@ +

Listing Notes

+ +<%= if @live_action in [:new, :edit] do %> + <.modal return_to={Routes.note_index_path(@socket, :index)}> + <.live_component + module={MemexWeb.NoteLive.FormComponent} + id={@note.id || :new} + title={@page_title} + action={@live_action} + note={@note} + return_to={Routes.note_index_path(@socket, :index)} + /> + +<% end %> + + + + + + + + + + + + + + <%= for note <- @notes do %> + + + + + + + + + <% end %> + +
TitleContentTagVisibility
<%= note.title %><%= note.content %><%= note.tag %><%= note.visibility %> + <%= live_redirect "Show", to: Routes.note_show_path(@socket, :show, note) %> + <%= live_patch "Edit", to: Routes.note_index_path(@socket, :edit, note) %> + <%= link "Delete", to: "#", phx_click: "delete", phx_value_id: note.id, data: [confirm: "Are you sure?"] %> +
+ +<%= live_patch "New Note", to: Routes.note_index_path(@socket, :new) %> diff --git a/lib/memex_web/live/note_live/show.ex b/lib/memex_web/live/note_live/show.ex new file mode 100644 index 0000000..4645fb4 --- /dev/null +++ b/lib/memex_web/live/note_live/show.ex @@ -0,0 +1,21 @@ +defmodule MemexWeb.NoteLive.Show do + use MemexWeb, :live_view + + alias Memex.Notes + + @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(:note, Notes.get_note!(id))} + end + + defp page_title(:show), do: "Show Note" + defp page_title(:edit), do: "Edit Note" +end diff --git a/lib/memex_web/live/note_live/show.html.heex b/lib/memex_web/live/note_live/show.html.heex new file mode 100644 index 0000000..ae040ba --- /dev/null +++ b/lib/memex_web/live/note_live/show.html.heex @@ -0,0 +1,41 @@ +

Show Note

+ +<%= if @live_action in [:edit] do %> + <.modal return_to={Routes.note_show_path(@socket, :show, @note)}> + <.live_component + module={MemexWeb.NoteLive.FormComponent} + id={@note.id} + title={@page_title} + action={@live_action} + note={@note} + return_to={Routes.note_show_path(@socket, :show, @note)} + /> + +<% end %> + + + +<%= live_patch "Edit", to: Routes.note_show_path(@socket, :edit, @note), class: "button" %> | +<%= live_redirect "Back", to: Routes.note_index_path(@socket, :index) %> diff --git a/lib/memex_web/router.ex b/lib/memex_web/router.ex index 50a5279..ebbe05a 100644 --- a/lib/memex_web/router.ex +++ b/lib/memex_web/router.ex @@ -53,23 +53,38 @@ defmodule MemexWeb.Router do put "/users/reset_password/:token", UserResetPasswordController, :update end - scope "/", MemexWeb do - pipe_through [:browser, :require_authenticated_user] + live_session :default do + scope "/", MemexWeb do + pipe_through [:browser] - get "/users/settings", UserSettingsController, :edit - put "/users/settings", UserSettingsController, :update - delete "/users/settings/:id", UserSettingsController, :delete - get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email + live "/notes", NoteLive.Index, :index + live "/notes/:id", NoteLive.Show, :show + end + + scope "/", MemexWeb do + pipe_through [:browser, :require_authenticated_user] + + live "/notes/new", NoteLive.Index, :new + live "/notes/:id/edit", NoteLive.Index, :edit + live "/notes/:id/show/edit", NoteLive.Show, :edit + + get "/users/settings", UserSettingsController, :edit + put "/users/settings", UserSettingsController, :update + delete "/users/settings/:id", UserSettingsController, :delete + get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email + end end - scope "/", MemexWeb do - pipe_through [:browser, :require_authenticated_user, :require_admin] + live_session :admin do + scope "/", MemexWeb do + pipe_through [:browser, :require_authenticated_user, :require_admin] - live_dashboard "/dashboard", metrics: MemexWeb.Telemetry, ecto_repos: [Memex.Repo] + live_dashboard "/dashboard", metrics: MemexWeb.Telemetry, ecto_repos: [Memex.Repo] - live "/invites", InviteLive.Index, :index - live "/invites/new", InviteLive.Index, :new - live "/invites/:id/edit", InviteLive.Index, :edit + live "/invites", InviteLive.Index, :index + live "/invites/new", InviteLive.Index, :new + live "/invites/:id/edit", InviteLive.Index, :edit + end end scope "/", MemexWeb do diff --git a/priv/repo/migrations/20220726000552_create_notes.exs b/priv/repo/migrations/20220726000552_create_notes.exs new file mode 100644 index 0000000..8c6495a --- /dev/null +++ b/priv/repo/migrations/20220726000552_create_notes.exs @@ -0,0 +1,15 @@ +defmodule Memex.Repo.Migrations.CreateNotes do + use Ecto.Migration + + def change do + create table(:notes, primary_key: false) do + add :id, :binary_id, primary_key: true + add :title, :string + add :content, :text + add :tag, {:array, :string} + add :visibility, :string + + timestamps() + end + end +end diff --git a/test/memex/notes_test.exs b/test/memex/notes_test.exs new file mode 100644 index 0000000..06b6d98 --- /dev/null +++ b/test/memex/notes_test.exs @@ -0,0 +1,65 @@ +defmodule Memex.NotesTest do + use Memex.DataCase + + alias Memex.Notes + + describe "notes" do + alias Memex.Notes.Note + + import Memex.NotesFixtures + + @invalid_attrs %{content: nil, tag: nil, title: nil, visibility: nil} + + test "list_notes/0 returns all notes" do + note = note_fixture() + assert Notes.list_notes() == [note] + end + + test "get_note!/1 returns the note with given id" do + note = note_fixture() + assert Notes.get_note!(note.id) == note + end + + test "create_note/1 with valid data creates a note" do + valid_attrs = %{content: "some content", tag: [], title: "some title", visibility: :public} + + assert {:ok, %Note{} = note} = Notes.create_note(valid_attrs) + assert note.content == "some content" + assert note.tag == [] + assert note.title == "some title" + assert note.visibility == :public + end + + test "create_note/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Notes.create_note(@invalid_attrs) + end + + test "update_note/2 with valid data updates the note" do + note = note_fixture() + update_attrs = %{content: "some updated content", tag: [], title: "some updated title", visibility: :private} + + assert {:ok, %Note{} = note} = Notes.update_note(note, update_attrs) + assert note.content == "some updated content" + assert note.tag == [] + assert note.title == "some updated title" + assert note.visibility == :private + end + + test "update_note/2 with invalid data returns error changeset" do + note = note_fixture() + assert {:error, %Ecto.Changeset{}} = Notes.update_note(note, @invalid_attrs) + assert note == Notes.get_note!(note.id) + end + + test "delete_note/1 deletes the note" do + note = note_fixture() + assert {:ok, %Note{}} = Notes.delete_note(note) + assert_raise Ecto.NoResultsError, fn -> Notes.get_note!(note.id) end + end + + test "change_note/1 returns a note changeset" do + note = note_fixture() + assert %Ecto.Changeset{} = Notes.change_note(note) + end + end +end diff --git a/test/memex_web/live/note_live_test.exs b/test/memex_web/live/note_live_test.exs new file mode 100644 index 0000000..acbde53 --- /dev/null +++ b/test/memex_web/live/note_live_test.exs @@ -0,0 +1,110 @@ +defmodule MemexWeb.NoteLiveTest do + use MemexWeb.ConnCase + + import Phoenix.LiveViewTest + import Memex.NotesFixtures + + @create_attrs %{content: "some content", tag: [], title: "some title", visibility: :public} + @update_attrs %{content: "some updated content", tag: [], title: "some updated title", visibility: :private} + @invalid_attrs %{content: nil, tag: [], title: nil, visibility: nil} + + defp create_note(_) do + note = note_fixture() + %{note: note} + end + + describe "Index" do + setup [:create_note] + + test "lists all notes", %{conn: conn, note: note} do + {:ok, _index_live, html} = live(conn, Routes.note_index_path(conn, :index)) + + assert html =~ "Listing Notes" + assert html =~ note.content + end + + test "saves new note", %{conn: conn} do + {:ok, index_live, _html} = live(conn, Routes.note_index_path(conn, :index)) + + assert index_live |> element("a", "New Note") |> render_click() =~ + "New Note" + + assert_patch(index_live, Routes.note_index_path(conn, :new)) + + assert index_live + |> form("#note-form", note: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#note-form", note: @create_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.note_index_path(conn, :index)) + + assert html =~ "Note created successfully" + assert html =~ "some content" + end + + test "updates note in listing", %{conn: conn, note: note} do + {:ok, index_live, _html} = live(conn, Routes.note_index_path(conn, :index)) + + assert index_live |> element("#note-#{note.id} a", "Edit") |> render_click() =~ + "Edit Note" + + assert_patch(index_live, Routes.note_index_path(conn, :edit, note)) + + assert index_live + |> form("#note-form", note: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#note-form", note: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.note_index_path(conn, :index)) + + assert html =~ "Note updated successfully" + assert html =~ "some updated content" + end + + test "deletes note in listing", %{conn: conn, note: note} do + {:ok, index_live, _html} = live(conn, Routes.note_index_path(conn, :index)) + + assert index_live |> element("#note-#{note.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#note-#{note.id}") + end + end + + describe "Show" do + setup [:create_note] + + test "displays note", %{conn: conn, note: note} do + {:ok, _show_live, html} = live(conn, Routes.note_show_path(conn, :show, note)) + + assert html =~ "Show Note" + assert html =~ note.content + end + + test "updates note within modal", %{conn: conn, note: note} do + {:ok, show_live, _html} = live(conn, Routes.note_show_path(conn, :show, note)) + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Note" + + assert_patch(show_live, Routes.note_show_path(conn, :edit, note)) + + assert show_live + |> form("#note-form", note: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + show_live + |> form("#note-form", note: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.note_show_path(conn, :show, note)) + + assert html =~ "Note updated successfully" + assert html =~ "some updated content" + end + end +end diff --git a/test/support/fixtures/notes_fixtures.ex b/test/support/fixtures/notes_fixtures.ex new file mode 100644 index 0000000..6548cc3 --- /dev/null +++ b/test/support/fixtures/notes_fixtures.ex @@ -0,0 +1,23 @@ +defmodule Memex.NotesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Memex.Notes` context. + """ + + @doc """ + Generate a note. + """ + def note_fixture(attrs \\ %{}) do + {:ok, note} = + attrs + |> Enum.into(%{ + content: "some content", + tag: [], + title: "some title", + visibility: :public + }) + |> Memex.Notes.create_note() + + note + end +end