Skip to content
Snippets Groups Projects
Commit b7624e3e authored by kerdo's avatar kerdo
Browse files

Merge branch '24-fr-14-mark-property-as-favorite' into 'main'

Resolve "FR-14: Mark Property as Favorite"

Closes #24 and #42

See merge request !24
parents c8828ebe 6676c7c7
No related branches found
No related tags found
1 merge request!24Resolve "FR-14: Mark Property as Favorite"
Pipeline #45301 passed
Showing
with 659 additions and 45 deletions
......@@ -34,4 +34,4 @@ proptrackr-*.tar
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
/assets/package-lock.json
\ No newline at end of file
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* This file is for your main application CSS */
.favorite-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
transition: transform 0.2s;
}
.favorite-button:hover {
transform: scale(1.1);
}
.flash-message {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem;
border-radius: 4px;
animation: slide-in 0.3s ease-out;
z-index: 1000;
}
.flash-message.success {
background-color: #4caf50;
color: white;
}
.flash-message.error {
background-color: #f44336;
color: white;
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
\ No newline at end of file
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
import "phoenix_html";
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import topbar from "../vendor/topbar";
import "../css/app.css";
import "./favorites";
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}
})
params: { _csrf_token: csrfToken },
});
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300));
window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide());
// connect if there are any LiveViews on the page
liveSocket.connect()
liveSocket.connect();
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
window.liveSocket = liveSocket;
document.addEventListener("DOMContentLoaded", function () {
const favoriteButtons = document.querySelectorAll(".favorite-button");
favoriteButtons.forEach((button) => {
button.addEventListener("click", async function (e) {
e.preventDefault();
const reference = this.dataset.propertyReference;
const isFavorited = this.dataset.favorited === "true";
try {
const response = await fetch(`/api/properties/${reference}/favorite`, {
method: isFavorited ? "DELETE" : "POST",
headers: {
"Content-Type": "application/json",
"x-csrf-token": document.querySelector("meta[name='csrf-token']")
.content,
},
});
const data = await response.json();
if (response.ok) {
this.textContent = isFavorited ? "" : "";
this.dataset.favorited = (!isFavorited).toString();
showFlashMessage("success", data.message);
} else {
showFlashMessage("error", data.error);
}
} catch (error) {
console.error("Error:", error);
showFlashMessage("error", "Something went wrong");
}
});
});
});
function showFlashMessage(type, message) {
const flashContainer = document.createElement("div");
flashContainer.className = `flash-message ${type}`;
flashContainer.textContent = message;
document.body.appendChild(flashContainer);
setTimeout(() => {
flashContainer.style.animation = "fadeOut 0.3s ease-out";
flashContainer.addEventListener("animationend", () =>
flashContainer.remove()
);
}, 3000);
}
{
"dependencies": {
"@tailwindcss/forms": "^0.5.9",
"tailwindcss": "^3.4.15"
}
}
......@@ -45,4 +45,8 @@ defmodule WhiteBreadConfig do
suite name: "FR-12 View Own Advertisements",
context: MyAdvertisementsContext,
feature_paths: ["features/my_advertisements.feature"]
suite name: "FR-14 Mark Property as Favorite",
context: MarkPropertyFavoriteContext,
feature_paths: ["features/mark_property_favorite.feature"]
end
defmodule MarkPropertyFavoriteContext do
use WhiteBread.Context
use Hound.Helpers
alias PropTrackr.Accounts
alias PropTrackr.Repo
alias PropTrackr.Accounts.User
alias PropTrackr.Properties.Property
alias PropTrackr.Favorites.Favorite
scenario_starting_state fn _state ->
Ecto.Adapters.SQL.Sandbox.checkout(PropTrackr.Repo)
Ecto.Adapters.SQL.Sandbox.mode(PropTrackr.Repo, {:shared, self()})
Hound.start_session()
%{}
end
scenario_finalize fn _status, _state ->
Ecto.Adapters.SQL.Sandbox.checkin(PropTrackr.Repo)
Hound.end_session()
end
given_ ~r/^there exists following accounts$/, fn state, %{table_data: table} ->
table
|> Enum.map(fn user_details -> User.changeset(%User{}, user_details) end)
|> Enum.each(fn changeset -> Repo.insert!(changeset) end)
random_user = List.first(table)
owner = List.last(table)
{
:ok,
state
|> Map.put(:random_email, random_user[:email])
|> Map.put(:random_password, random_user[:password])
|> Map.put(:owner_email, owner[:email])
|> Map.put(:owner_password, owner[:password])
}
end
and_ ~r/^the following properties exist$/, fn state, %{ table_data: table } ->
owner = Repo.get_by(User, email: state[:owner_email])
advertisements =
table
|> Enum.map(fn details -> details |> Map.put(:reference, Ecto.UUID.generate()) end)
|> Enum.map(fn details -> {Ecto.build_assoc(owner, :properties, details), details} end)
|> Enum.map(fn {assoc, details} -> Property.changeset(assoc, details) end)
|> Enum.map(fn changeset -> Repo.insert!(changeset) end)
{
:ok,
state
|> Map.put(:advertisements, advertisements)
}
end
and_ ~r/^I am logged in as random user$/, fn state ->
setup_session(state[:random_email], state[:random_password])
{:ok, state}
end
and_ ~r/^I am logged in as owner$/, fn state ->
setup_session(state[:owner_email], state[:owner_password])
{:ok, state}
end
when_ ~r/^I visit the home page$/, fn state ->
navigate_to("/")
{:ok, state}
end
when_ ~r/^I visit the details page for "(?<argument_one>[^"]+)"$/,
fn state, %{argument_one: argument_one} ->
property = Enum.find(state.advertisements, fn advert ->
advert.title == argument_one
end)
navigate_to("/properties/#{property.reference}")
:timer.sleep(500)
{:ok, state}
end
and_ ~r/^I click the favorite button$/, fn state ->
button = find_element(:css, ".favorite-button")
click(button)
:timer.sleep(500)
reference = attribute_value(button, "data-property-reference")
property = Enum.find(state.advertisements, fn advert ->
advert.reference == reference
end)
{:ok, state |> Map.put(:last_favorited_title, property.title)}
end
and_ ~r/^I click the favorite button for "(?<argument_one>[^"]+)"$/,
fn state, %{argument_one: argument_one} ->
property = Enum.find(state.advertisements, fn advert ->
advert.title == argument_one
end)
button = find_element(:css, ".favorite-button[data-property-reference='#{property.reference}']")
click(button)
:timer.sleep(500)
{:ok, state |> Map.put(:last_favorited_title, argument_one)}
end
then_ ~r/^I should see Property added to favorites message$/, fn state ->
assert visible_in_page? ~r/Property added to favorites/
{:ok, state}
end
then_ ~r/^I should not see a favorite button for "(?<argument_one>[^"]+)"$/,
fn state, %{argument_one: argument_one} ->
property = Enum.find(state.advertisements, fn advert ->
advert.title == argument_one
end)
elements = find_all_elements(:css, ".favorite-button[data-property-reference='#{property.reference}']")
assert Enum.empty?(elements),
"Expected no favorite button for property '#{argument_one}' but found #{length(elements)} button(s)"
{:ok, state}
end
and_ ~r/^the property should be marked as favorite$/, fn state ->
property = Enum.find(state.advertisements, fn advert ->
advert.title == state.last_favorited_title
end)
button = find_element(:css, ".favorite-button[data-property-reference='#{property.reference}']")
filled_star = "★"
button_text = visible_text(button)
assert button_text == filled_star, "Expected filled star (#{filled_star}) but got #{button_text}"
favorited_attr = attribute_value(button, "data-favorited")
assert favorited_attr == "true", "Expected data-favorited to be 'true' but got #{favorited_attr}"
{:ok, state}
end
defp setup_session(email, password) do
navigate_to("/login")
fill_field({:id, "email"}, email)
fill_field({:id, "password"}, password)
click({:id, "login_button"})
end
end
Feature: Property Favorite
Scenario: User can favorite a property from home page
Given there exists following accounts
| name | surname | birth_date | phone_number | email | password | confirm_password |
| Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password |
| Property | Owner | 2000-01-01 | 111 | property.owner@gmail.com | password | password |
And the following properties exist
| title | description | type | property_type | state | location | room_count | area | floor | floor_count | price |
| Really cool property | Selling this really really house | sell | house | available | London | 3 | 100.0 | 2 | 5 | 500000 |
And I am logged in as random user
When I visit the home page
And I click the favorite button for "Really cool property"
Then I should see Property added to favorites message
And the property should be marked as favorite
Scenario: User cannot favorite their own property
Given there exists following accounts
| name | surname | birth_date | phone_number | email | password | confirm_password |
| Property | Owner | 2000-01-01 | 111 | property.owner@gmail.com | password | password |
And the following properties exist
| title | description | type | property_type | state | location | room_count | area | floor | floor_count | price |
| Really cool property | Selling this really really house | sell | house | available | London | 3 | 100.0 | 2 | 5 | 500000 |
And I am logged in as owner
When I visit the home page
Then I should not see a favorite button for "Really cool property"
Scenario: User can favorite a property from property details page
Given there exists following accounts
| name | surname | birth_date | phone_number | email | password | confirm_password |
| Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password |
| Property | Owner | 2000-01-01 | 111 | property.owner@gmail.com | password | password |
And the following properties exist
| title | description | type | property_type | state | location | room_count | area | floor | floor_count | price |
| Really cool property | Selling this really really house | sell | house | available | London | 3 | 100.0 | 2 | 5 | 500000 |
And I am logged in as random user
When I visit the details page for "Really cool property"
And I click the favorite button
Then I should see Property added to favorites message
And the property should be marked as favorite
\ No newline at end of file
defmodule PropTrackr.Favorites.Favorite do
use Ecto.Schema
import Ecto.Changeset
schema "favorites" do
belongs_to :user, PropTrackr.Accounts.User
belongs_to :property, PropTrackr.Properties.Property
timestamps()
end
def changeset(favorite, attrs \\ %{}) do
favorite
|> cast(attrs, [:user_id, :property_id])
|> validate_required([:user_id, :property_id])
|> unique_constraint([:user_id, :property_id])
end
end
defmodule PropTrackrWeb.FavoriteController do
use PropTrackrWeb, :controller
import Ecto.Query
alias PropTrackr.Repo
alias PropTrackr.Properties.Property
alias PropTrackr.Favorites.Favorite
def create(conn, %{"reference" => reference}) do
current_user = conn.assigns.current_user
if current_user == nil do
conn
|> put_status(:unauthorized)
|> json(%{error: "You're not logged in!"})
else
case Repo.one(from p in Property, where: p.reference == ^reference) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Property not found"})
property ->
if property.user_id == current_user.id do
conn
|> put_status(:forbidden)
|> json(%{error: "Cannot set own property as favorite"})
else
favorite_changeset = %Favorite{}
|> Favorite.changeset(%{
user_id: current_user.id,
property_id: property.id
})
case Repo.insert(favorite_changeset) do
{:ok, _favorite} ->
conn
|> render(:message, message: "Property added to favorites")
{:error, _changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render(:error, error: "Already in favorites")
end
end
end
end
end
def delete(conn, %{"reference" => reference}) do
current_user = conn.assigns.current_user
if current_user == nil do
conn
|> put_status(:unauthorized)
|> json(%{error: "You must be logged in"})
else
case Repo.one(from p in Property, where: p.reference == ^reference) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Property not found"})
property ->
favorite = Repo.one(
from f in Favorite,
where: f.user_id == ^current_user.id and f.property_id == ^property.id
)
case favorite do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Favorite not found"})
favorite ->
case Repo.delete(favorite) do
{:ok, _} ->
json(conn, %{message: "Property removed from favorites"})
{:error, _} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to remove favorite"})
end
end
end
end
end
end
defmodule PropTrackrWeb.FavoriteJSON do
def message(%{message: message}) do
%{message: message}
end
def error(%{error: error}) do
%{error: error}
end
end
......@@ -4,14 +4,30 @@ defmodule PropTrackrWeb.PropertiesController do
import Ecto.Query, only: [from: 2]
alias PropTrackr.Repo
alias PropTrackr.Properties.Property
alias PropTrackr.Favorites.Favorite
def index(conn, _params) do
properties = Repo.all(
from p in Property,
where: p.state == :available,
order_by: [asc: p.inserted_at],
select: p)
render conn, "index.html", properties: properties
favorites = case conn.assigns.current_user do
nil -> []
current_user ->
Repo.all(
from f in Favorite,
where: f.user_id == ^current_user.id,
select: f.property_id
)
end
render(conn, "index.html",
properties: properties,
favorites: favorites
)
end
def show(conn, %{"reference" => reference}) do
......@@ -25,13 +41,27 @@ defmodule PropTrackrWeb.PropertiesController do
current_user = conn.assigns.current_user
can_edit = current_user && current_user.id == property.user_id
favorites = case current_user do
nil -> []
user ->
Repo.all(
from f in Favorite,
where: f.user_id == ^user.id,
select: f.property_id
)
end
case property do
nil ->
conn
|> put_flash(:error, "Property not found.")
|> redirect(to: ~p"/")
property ->
render(conn, "show.html", property: property, can_edit: can_edit)
render(conn, "show.html",
property: property,
can_edit: can_edit,
favorites: favorites
)
end
end
......
......@@ -5,7 +5,9 @@
<%= if @conn.assigns[:current_user] do %>
<div class="flex flex-row justify-end gap-x-2">
<.link href={~p"/properties/new"}>
<.button type="button" class="text-white rounded px-4 py-2" id="add_advertisement">Add a property</.button>
<.button type="button" class="text-white rounded px-4 py-2" id="add_advertisement">
Add a property
</.button>
</.link>
</div>
<% end %>
......@@ -17,22 +19,38 @@
<div id="properties" class="flex flex-col gap-y-4 mt-20">
<%= for property <- @properties do %>
<div class="bg-white border-black border rounded px-4 py-2">
<h2 class="font-bold"><%= property.title %></h2>
<div class="flex justify-between items-start">
<h2 class="font-bold"><%= property.title %></h2>
<%= if @conn.assigns[:current_user] && @conn.assigns.current_user.id != property.user_id do %>
<button
type="button"
class="favorite-button text-2xl"
data-property-reference={property.reference}
data-favorited={if property.id in @favorites, do: "true", else: "false"}
>
<%= if property.id in @favorites, do: "★", else: "☆" %>
</button>
<% end %>
</div>
<p><%= property.description %></p>
<p class="italic"><%= property.location %></p>
<div class="flex flex-row gap-x-2">
<span><%= property.price %></span>
<span><%= property.room_count %> rooms</span>
<span><%= property.area %> m<sup>2</sup></span>
</div>
<div class="flex flex-row justify-end">
<.link href={~p"/properties/#{property.reference}"}>
<.button type="button" class="text-white rounded px-4 py-2" id={ "edit-#{property.reference}" }>View more</.button>
<.button
type="button"
class="text-white rounded px-4 py-2"
id={"edit-#{property.reference}"}
>
View more
</.button>
</.link>
</div>
</div>
<% end %>
</div>
......@@ -22,6 +22,18 @@
Delete Property
</.link>
<% end %>
<%= if @conn.assigns[:current_user] && !@can_edit do %>
<button
type="button"
class="favorite-button text-2xl mr-4"
data-property-reference={@property.reference}
data-favorited={if @property.id in @favorites, do: "true", else: "false"}
>
<%= if @property.id in @favorites, do: "★", else: "☆" %>
</button>
<% end %>
<.link href={~p"/"}>
Back to listings <.icon name="hero-arrow-right-solid" class="h-3 w-3" />
</.link>
......
......@@ -13,6 +13,10 @@ defmodule PropTrackrWeb.Router do
pipeline :api do
plug :accepts, ["json"]
plug :fetch_session
plug :protect_from_forgery
plug :put_secure_browser_headers
plug PropTrackr.Authentication, repo: PropTrackr.Repo
end
scope "/", PropTrackrWeb do
......@@ -42,6 +46,13 @@ defmodule PropTrackrWeb.Router do
delete "/properties/:reference", PropertiesController, :delete
end
scope "/api", PropTrackrWeb do
pipe_through :api
post "/properties/:reference/favorite", FavoriteController, :create
delete "/properties/:reference/favorite", FavoriteController, :delete
end
# Other scopes may use custom stacks.
# scope "/api", PropTrackrWeb do
# pipe_through :api
......
defmodule PropTrackr.Repo.Migrations.AddFavorites do
use Ecto.Migration
def change do
create table(:favorites) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :property_id, references(:properties, on_delete: :delete_all), null: false
timestamps()
end
create unique_index(:favorites, [:user_id, :property_id])
end
end
defmodule PropTrackrWeb.FavoriteControllerTest do
use PropTrackrWeb.ConnCase
alias PropTrackr.Accounts.User
alias PropTrackr.Properties.Property
alias PropTrackr.Favorites.Favorite
alias PropTrackr.Repo
import Ecto.Query, only: [from: 2]
setup do
owner = %User{
name: "Property",
surname: "Owner",
birth_date: "2000-01-01",
phone_number: "111",
bio: "Hey",
email: "property.owner@gmail.com",
password: "password",
confirm_password: "password"
}
owner = Repo.insert!(owner)
random_user = %User{
name: "Random",
surname: "User",
birth_date: "2000-01-01",
phone_number: "000",
bio: "Hi",
email: "random.user@gmail.com",
password: "password",
confirm_password: "password"
}
random_user = Repo.insert!(random_user)
property = %Property{
reference: Ecto.UUID.generate(),
title: "Test Property",
description: "Test Description",
type: :sell,
property_type: :house,
state: :available,
location: "Test Location",
room_count: 3,
area: 100.0,
floor: 2,
floor_count: 5,
price: 500000.0,
user_id: owner.id
}
property = Repo.insert!(property)
{:ok, %{owner: owner, random_user: random_user, property: property}}
end
test "authenticated user can favorite a property", %{conn: conn, random_user: user, property: property} do
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/properties/#{property.reference}/favorite")
IO.inspect(conn)
assert json_response(conn, 200)["message"] == "Property added to favorites"
favorite = Repo.one(from f in Favorite,
where: f.user_id == ^user.id and f.property_id == ^property.id)
assert favorite != nil
end
test "authenticated user cannot favorite same property twice", %{conn: conn, random_user: user, property: property} do
Repo.insert!(%Favorite{user_id: user.id, property_id: property.id})
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/properties/#{property.reference}/favorite")
assert json_response(conn, 422)["error"] == "Already in favorites"
#database should still only have one favorite
favorites_count = Repo.one(from f in Favorite,
where: f.user_id == ^user.id and f.property_id == ^property.id,
select: count(f.id))
assert favorites_count == 1
end
test "owner cannot favorite their own property", %{conn: conn, owner: owner, property: property} do
conn = conn |> setup_session(owner)
conn = post(conn, ~p"/api/properties/#{property.reference}/favorite")
assert json_response(conn, 403)["error"] == "Cannot set own property as favorite"
#no favorite should be created
favorite = Repo.one(from f in Favorite,
where: f.user_id == ^owner.id and f.property_id == ^property.id)
assert favorite == nil
end
test "unauthenticated user cannot favorite a property", %{conn: conn, property: property} do
initial_favorites_count = Repo.one(from f in Favorite, select: count(f.id))
conn = post(conn, ~p"/api/properties/#{property.reference}/favorite")
assert json_response(conn, 401)["error"] == "You're not logged in!"
# count should not change
final_favorites_count = Repo.one(from f in Favorite, select: count(f.id))
assert initial_favorites_count == final_favorites_count
end
test "cannot favorite non-existent property", %{conn: conn, random_user: user} do
initial_favorites_count = Repo.one(from f in Favorite, select: count(f.id))
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/properties/non-existent-reference/favorite")
assert json_response(conn, 404)["error"] == "Property not found"
#count should not change
final_favorites_count = Repo.one(from f in Favorite, select: count(f.id))
assert initial_favorites_count == final_favorites_count
end
defp setup_session(conn, user) do
conn = conn |> post("/login", email: user.email, password: user.password)
conn = get conn, redirected_to(conn)
conn
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment