diff --git a/lib/real_estate/properties.ex b/lib/real_estate/properties.ex new file mode 100644 index 0000000..bca299a --- /dev/null +++ b/lib/real_estate/properties.ex @@ -0,0 +1,104 @@ +defmodule RealEstate.Properties do + @moduledoc """ + The Properties context. + """ + + import Ecto.Query, warn: false + alias RealEstate.Repo + + alias RealEstate.Properties.Property + + @doc """ + Returns the list of properties. + + ## Examples + + iex> list_properties() + [%Property{}, ...] + + """ + def list_properties do + Repo.all(Property) + end + + @doc """ + Gets a single property. + + Raises `Ecto.NoResultsError` if the Property does not exist. + + ## Examples + + iex> get_property!(123) + %Property{} + + iex> get_property!(456) + ** (Ecto.NoResultsError) + + """ + def get_property!(id), do: Repo.get!(Property, id) + + @doc """ + Creates a property. + + ## Examples + + iex> create_property(%{field: value}) + {:ok, %Property{}} + + iex> create_property(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_property(attrs \\ %{}) do + %Property{} + |> Property.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a property. + + ## Examples + + iex> update_property(property, %{field: new_value}) + {:ok, %Property{}} + + iex> update_property(property, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_property(%Property{} = property, attrs) do + property + |> Property.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a property. + + ## Examples + + iex> delete_property(property) + {:ok, %Property{}} + + iex> delete_property(property) + {:error, %Ecto.Changeset{}} + + """ + def delete_property(%Property{} = property) do + Repo.delete(property) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking property changes. + + ## Examples + + iex> change_property(property) + %Ecto.Changeset{data: %Property{}} + + """ + def change_property(%Property{} = property, attrs \\ %{}) do + Property.changeset(property, attrs) + end +end diff --git a/lib/real_estate/properties/property.ex b/lib/real_estate/properties/property.ex new file mode 100644 index 0000000..2786b1f --- /dev/null +++ b/lib/real_estate/properties/property.ex @@ -0,0 +1,20 @@ +defmodule RealEstate.Properties.Property do + use Ecto.Schema + import Ecto.Changeset + + schema "properties" do + field :description, :string + field :name, :string + field :price, :decimal + field :user_id, :id + + timestamps() + end + + @doc false + def changeset(property, attrs) do + property + |> cast(attrs, [:name, :price, :description]) + |> validate_required([:name, :price, :description]) + end +end diff --git a/lib/real_estate_web/live/live_helpers.ex b/lib/real_estate_web/live/live_helpers.ex index 7bbc9f6..9f085d9 100644 --- a/lib/real_estate_web/live/live_helpers.ex +++ b/lib/real_estate_web/live/live_helpers.ex @@ -3,6 +3,27 @@ defmodule RealEstateWeb.LiveHelpers do alias RealEstate.Accounts alias RealEstate.Accounts.User alias RealEstateWeb.Router.Helpers, as: Routes + import Phoenix.LiveView.Helpers + + @doc """ + Renders a component inside the `RealEstateWeb.ModalComponent` component. + + The rendered modal receives a `:return_to` option to properly update + the URL when the modal is closed. + + ## Examples + + <%= live_modal @socket, RealEstateWeb.PropertyLive.FormComponent, + id: @property.id || :new, + action: @live_action, + property: @property, + return_to: Routes.property_index_path(@socket, :index) %> + """ + def live_modal(socket, component, opts) do + path = Keyword.fetch!(opts, :return_to) + modal_opts = [id: :modal, return_to: path, component: component, opts: opts] + live_component(socket, RealEstateWeb.ModalComponent, modal_opts) + end def assign_defaults(session, socket) do socket = diff --git a/lib/real_estate_web/live/modal_component.ex b/lib/real_estate_web/live/modal_component.ex new file mode 100644 index 0000000..1f10e59 --- /dev/null +++ b/lib/real_estate_web/live/modal_component.ex @@ -0,0 +1,26 @@ +defmodule RealEstateWeb.ModalComponent do + use RealEstateWeb, :live_component + + @impl true + def render(assigns) do + ~L""" +
+ +
+ <%= live_patch raw("×"), to: @return_to, class: "phx-modal-close" %> + <%= live_component @socket, @component, @opts %> +
+
+ """ + end + + @impl true + def handle_event("close", _, socket) do + {:noreply, push_patch(socket, to: socket.assigns.return_to)} + end +end diff --git a/lib/real_estate_web/live/property_live/form_component.ex b/lib/real_estate_web/live/property_live/form_component.ex new file mode 100644 index 0000000..7097e6e --- /dev/null +++ b/lib/real_estate_web/live/property_live/form_component.ex @@ -0,0 +1,55 @@ +defmodule RealEstateWeb.PropertyLive.FormComponent do + use RealEstateWeb, :live_component + + alias RealEstate.Properties + + @impl true + def update(%{property: property} = assigns, socket) do + changeset = Properties.change_property(property) + + {:ok, + socket + |> assign(assigns) + |> assign(:changeset, changeset)} + end + + @impl true + def handle_event("validate", %{"property" => property_params}, socket) do + changeset = + socket.assigns.property + |> Properties.change_property(property_params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :changeset, changeset)} + end + + def handle_event("save", %{"property" => property_params}, socket) do + save_property(socket, socket.assigns.action, property_params) + end + + defp save_property(socket, :edit, property_params) do + case Properties.update_property(socket.assigns.property, property_params) do + {:ok, _property} -> + {:noreply, + socket + |> put_flash(:info, "Property updated successfully") + |> push_redirect(to: socket.assigns.return_to)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :changeset, changeset)} + end + end + + defp save_property(socket, :new, property_params) do + case Properties.create_property(property_params) do + {:ok, _property} -> + {:noreply, + socket + |> put_flash(:info, "Property 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/real_estate_web/live/property_live/form_component.html.leex b/lib/real_estate_web/live/property_live/form_component.html.leex new file mode 100644 index 0000000..31841ec --- /dev/null +++ b/lib/real_estate_web/live/property_live/form_component.html.leex @@ -0,0 +1,22 @@ +

<%= @title %>

+ +<%= f = form_for @changeset, "#", + id: "property-form", + phx_target: @myself, + phx_change: "validate", + phx_submit: "save" %> + + <%= label f, :name %> + <%= text_input f, :name %> + <%= error_tag f, :name %> + + <%= label f, :price %> + <%= number_input f, :price, step: "any" %> + <%= error_tag f, :price %> + + <%= label f, :description %> + <%= textarea f, :description %> + <%= error_tag f, :description %> + + <%= submit "Save", phx_disable_with: "Saving..." %> + diff --git a/lib/real_estate_web/live/property_live/index.ex b/lib/real_estate_web/live/property_live/index.ex new file mode 100644 index 0000000..edd658a --- /dev/null +++ b/lib/real_estate_web/live/property_live/index.ex @@ -0,0 +1,46 @@ +defmodule RealEstateWeb.PropertyLive.Index do + use RealEstateWeb, :live_view + + alias RealEstate.Properties + alias RealEstate.Properties.Property + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :properties, list_properties())} + 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 Property") + |> assign(:property, Properties.get_property!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Property") + |> assign(:property, %Property{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Properties") + |> assign(:property, nil) + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + property = Properties.get_property!(id) + {:ok, _} = Properties.delete_property(property) + + {:noreply, assign(socket, :properties, list_properties())} + end + + defp list_properties do + Properties.list_properties() + end +end diff --git a/lib/real_estate_web/live/property_live/index.html.leex b/lib/real_estate_web/live/property_live/index.html.leex new file mode 100644 index 0000000..3d99ff2 --- /dev/null +++ b/lib/real_estate_web/live/property_live/index.html.leex @@ -0,0 +1,39 @@ +

Listing Properties

+ +<%= if @live_action in [:new, :edit] do %> + <%= live_modal @socket, RealEstateWeb.PropertyLive.FormComponent, + id: @property.id || :new, + title: @page_title, + action: @live_action, + property: @property, + return_to: Routes.property_index_path(@socket, :index) %> +<% end %> + + + + + + + + + + + + + <%= for property <- @properties do %> + + + + + + + + <% end %> + +
NamePriceDescription
<%= property.name %><%= property.price %><%= property.description %> + <%= live_redirect "Show", to: Routes.property_show_path(@socket, :show, property) %> + <%= live_patch "Edit", to: Routes.property_index_path(@socket, :edit, property) %> + <%= link "Delete", to: "#", phx_click: "delete", phx_value_id: property.id, data: [confirm: "Are you sure?"] %> +
+ +<%= live_patch "New Property", to: Routes.property_index_path(@socket, :new) %> diff --git a/lib/real_estate_web/live/property_live/show.ex b/lib/real_estate_web/live/property_live/show.ex new file mode 100644 index 0000000..e044c1a --- /dev/null +++ b/lib/real_estate_web/live/property_live/show.ex @@ -0,0 +1,21 @@ +defmodule RealEstateWeb.PropertyLive.Show do + use RealEstateWeb, :live_view + + alias RealEstate.Properties + + @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(:property, Properties.get_property!(id))} + end + + defp page_title(:show), do: "Show Property" + defp page_title(:edit), do: "Edit Property" +end diff --git a/lib/real_estate_web/live/property_live/show.html.leex b/lib/real_estate_web/live/property_live/show.html.leex new file mode 100644 index 0000000..a342993 --- /dev/null +++ b/lib/real_estate_web/live/property_live/show.html.leex @@ -0,0 +1,32 @@ +

Show Property

+ +<%= if @live_action in [:edit] do %> + <%= live_modal @socket, RealEstateWeb.PropertyLive.FormComponent, + id: @property.id, + title: @page_title, + action: @live_action, + property: @property, + return_to: Routes.property_show_path(@socket, :show, @property) %> +<% end %> + + + +<%= live_patch "Edit", to: Routes.property_show_path(@socket, :edit, @property), class: "button" %> +<%= live_redirect "Back", to: Routes.property_index_path(@socket, :index) %> diff --git a/priv/repo/migrations/20200914162043_create_properties.exs b/priv/repo/migrations/20200914162043_create_properties.exs new file mode 100644 index 0000000..15b61da --- /dev/null +++ b/priv/repo/migrations/20200914162043_create_properties.exs @@ -0,0 +1,16 @@ +defmodule RealEstate.Repo.Migrations.CreateProperties do + use Ecto.Migration + + def change do + create table(:properties) do + add :name, :string + add :price, :decimal + add :description, :text + add :user_id, references(:users, on_delete: :nothing) + + timestamps() + end + + create index(:properties, [:user_id]) + end +end diff --git a/test/real_estate/properties_test.exs b/test/real_estate/properties_test.exs new file mode 100644 index 0000000..ae063e5 --- /dev/null +++ b/test/real_estate/properties_test.exs @@ -0,0 +1,68 @@ +defmodule RealEstate.PropertiesTest do + use RealEstate.DataCase + + alias RealEstate.Properties + + describe "properties" do + alias RealEstate.Properties.Property + + @valid_attrs %{description: "some description", name: "some name", price: "120.5"} + @update_attrs %{description: "some updated description", name: "some updated name", price: "456.7"} + @invalid_attrs %{description: nil, name: nil, price: nil} + + def property_fixture(attrs \\ %{}) do + {:ok, property} = + attrs + |> Enum.into(@valid_attrs) + |> Properties.create_property() + + property + end + + test "list_properties/0 returns all properties" do + property = property_fixture() + assert Properties.list_properties() == [property] + end + + test "get_property!/1 returns the property with given id" do + property = property_fixture() + assert Properties.get_property!(property.id) == property + end + + test "create_property/1 with valid data creates a property" do + assert {:ok, %Property{} = property} = Properties.create_property(@valid_attrs) + assert property.description == "some description" + assert property.name == "some name" + assert property.price == Decimal.new("120.5") + end + + test "create_property/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Properties.create_property(@invalid_attrs) + end + + test "update_property/2 with valid data updates the property" do + property = property_fixture() + assert {:ok, %Property{} = property} = Properties.update_property(property, @update_attrs) + assert property.description == "some updated description" + assert property.name == "some updated name" + assert property.price == Decimal.new("456.7") + end + + test "update_property/2 with invalid data returns error changeset" do + property = property_fixture() + assert {:error, %Ecto.Changeset{}} = Properties.update_property(property, @invalid_attrs) + assert property == Properties.get_property!(property.id) + end + + test "delete_property/1 deletes the property" do + property = property_fixture() + assert {:ok, %Property{}} = Properties.delete_property(property) + assert_raise Ecto.NoResultsError, fn -> Properties.get_property!(property.id) end + end + + test "change_property/1 returns a property changeset" do + property = property_fixture() + assert %Ecto.Changeset{} = Properties.change_property(property) + end + end +end diff --git a/test/real_estate_web/live/property_live_test.exs b/test/real_estate_web/live/property_live_test.exs new file mode 100644 index 0000000..928e23f --- /dev/null +++ b/test/real_estate_web/live/property_live_test.exs @@ -0,0 +1,116 @@ +defmodule RealEstateWeb.PropertyLiveTest do + use RealEstateWeb.ConnCase + + import Phoenix.LiveViewTest + + alias RealEstate.Properties + + @create_attrs %{description: "some description", name: "some name", price: "120.5"} + @update_attrs %{description: "some updated description", name: "some updated name", price: "456.7"} + @invalid_attrs %{description: nil, name: nil, price: nil} + + defp fixture(:property) do + {:ok, property} = Properties.create_property(@create_attrs) + property + end + + defp create_property(_) do + property = fixture(:property) + %{property: property} + end + + describe "Index" do + setup [:create_property] + + test "lists all properties", %{conn: conn, property: property} do + {:ok, _index_live, html} = live(conn, Routes.property_index_path(conn, :index)) + + assert html =~ "Listing Properties" + assert html =~ property.description + end + + test "saves new property", %{conn: conn} do + {:ok, index_live, _html} = live(conn, Routes.property_index_path(conn, :index)) + + assert index_live |> element("a", "New Property") |> render_click() =~ + "New Property" + + assert_patch(index_live, Routes.property_index_path(conn, :new)) + + assert index_live + |> form("#property-form", property: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#property-form", property: @create_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.property_index_path(conn, :index)) + + assert html =~ "Property created successfully" + assert html =~ "some description" + end + + test "updates property in listing", %{conn: conn, property: property} do + {:ok, index_live, _html} = live(conn, Routes.property_index_path(conn, :index)) + + assert index_live |> element("#property-#{property.id} a", "Edit") |> render_click() =~ + "Edit Property" + + assert_patch(index_live, Routes.property_index_path(conn, :edit, property)) + + assert index_live + |> form("#property-form", property: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + index_live + |> form("#property-form", property: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.property_index_path(conn, :index)) + + assert html =~ "Property updated successfully" + assert html =~ "some updated description" + end + + test "deletes property in listing", %{conn: conn, property: property} do + {:ok, index_live, _html} = live(conn, Routes.property_index_path(conn, :index)) + + assert index_live |> element("#property-#{property.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#property-#{property.id}") + end + end + + describe "Show" do + setup [:create_property] + + test "displays property", %{conn: conn, property: property} do + {:ok, _show_live, html} = live(conn, Routes.property_show_path(conn, :show, property)) + + assert html =~ "Show Property" + assert html =~ property.description + end + + test "updates property within modal", %{conn: conn, property: property} do + {:ok, show_live, _html} = live(conn, Routes.property_show_path(conn, :show, property)) + + assert show_live |> element("a", "Edit") |> render_click() =~ + "Edit Property" + + assert_patch(show_live, Routes.property_show_path(conn, :edit, property)) + + assert show_live + |> form("#property-form", property: @invalid_attrs) + |> render_change() =~ "can't be blank" + + {:ok, _, html} = + show_live + |> form("#property-form", property: @update_attrs) + |> render_submit() + |> follow_redirect(conn, Routes.property_show_path(conn, :show, property)) + + assert html =~ "Property updated successfully" + assert html =~ "some updated description" + end + end +end