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

Merge branch '26-fr-16-mark-property-as-not-interested' into 'main'

Implemented not interested task. Created a new table. All tests working

Closes #26

See merge request !30
parents c3185c32 4c7c34af
No related branches found
No related tags found
1 merge request!30Implemented not interested task. Created a new table. All tests working
Pipeline #46362 passed
Showing with 640 additions and 17 deletions
...@@ -5,6 +5,7 @@ import topbar from "../vendor/topbar"; ...@@ -5,6 +5,7 @@ import topbar from "../vendor/topbar";
import "../css/app.css"; import "../css/app.css";
import "./favorites"; import "./favorites";
import "./status"; import "./status";
import "./interest";
let csrfToken = document let csrfToken = document
.querySelector("meta[name='csrf-token']") .querySelector("meta[name='csrf-token']")
......
document.addEventListener("DOMContentLoaded", initializeInterestButtons);
function initializeInterestButtons() {
const menuButtons = document.querySelectorAll(".interest-menu-button");
menuButtons.forEach((button) => {
button.addEventListener("click", (e) => {
e.stopPropagation();
const menu = button.nextElementSibling;
menu.classList.toggle("hidden");
});
const options = button.nextElementSibling.querySelectorAll(".interest-option");
options.forEach((option) => {
option.addEventListener("click", handleInterestOptionClick);
});
});
}
async function handleInterestOptionClick(e) {
e.preventDefault();
const button = this.closest('.relative').querySelector('.interest-menu-button');
const reference = button.dataset.propertyReference;
const status = this.dataset.status;
const isNotInterested = status === "not_interested";
try {
const response = await fetch(`/api/properties/${reference}/interest`, {
method: isNotInterested ? "POST" : "DELETE",
headers: {
"Content-Type": "application/json",
"x-csrf-token": document.querySelector("meta[name='csrf-token']").content,
},
});
const data = await response.json();
if (response.ok) {
const menu = this.closest('.interest-menu');
const ticks = menu.querySelectorAll(".interest-tick");
ticks.forEach(tick => tick.classList.add("invisible"));
this.querySelector(".interest-tick").classList.remove("invisible");
menu.classList.add("hidden");
// Fetch and update content
const propertiesContainer = document.getElementById("properties");
propertiesContainer.innerHTML = await fetch(window.location.pathname)
.then(response => response.text())
.then(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.getElementById("properties").innerHTML;
});
// Initialize both sets of buttons
document.dispatchEvent(new Event('DOMContentLoaded'));
showFlashMessage("success", data.message);
} else {
showFlashMessage("error", data.error);
}
} catch (error) {
console.error("Error:", error);
showFlashMessage("error", "Something went wrong");
}
}
document.addEventListener("click", () => {
document.querySelectorAll(".interest-menu").forEach(menu => {
menu.classList.add("hidden");
});
});
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);
}
\ No newline at end of file
...@@ -64,4 +64,8 @@ defmodule WhiteBreadConfig do ...@@ -64,4 +64,8 @@ defmodule WhiteBreadConfig do
suite name: "FR-18 Display Similar Properties on Listing View", suite name: "FR-18 Display Similar Properties on Listing View",
context: SimilarPropertiesContext, context: SimilarPropertiesContext,
feature_paths: ["features/similar_properties_view.feature"] feature_paths: ["features/similar_properties_view.feature"]
suite name: "FR-16: Mark Property as Not Interested",
context: NotInterestedPropertiesContext,
feature_paths: ["features/not_interested_properties.feature"]
end end
defmodule NotInterestedPropertiesContext do
use WhiteBread.Context
use Hound.Helpers
import Ecto.Query
alias PropTrackr.Accounts
alias PropTrackr.Repo
alias PropTrackr.Accounts.User
alias PropTrackr.Properties.Property
alias PropTrackr.NotInterested
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
and_ ~r/^the property "(?<argument_one>[^"]+)" is marked as not interested$/,
fn state, %{argument_one: argument_one} ->
current_user = Repo.get_by(User, email: state[:random_email])
property = Enum.find(state.advertisements, fn advert ->
advert.title == argument_one
end)
not_interested_changeset = %NotInterested{}
|> NotInterested.changeset(%{
user_id: current_user.id,
property_id: property.id
})
Repo.insert!(not_interested_changeset)
{:ok, state}
end
when_ ~r/^I visit the home page$/, fn state ->
navigate_to("/")
{:ok, state}
end
and_ ~r/^I click interest menu for "(?<argument_one>[^"]+)"$/,
fn state, %{argument_one: argument_one} ->
property = Enum.find(state.advertisements, fn advert ->
advert.title == argument_one
end)
# Update selector to match your HTML
# Change this line in ManagePropertyInterestContext
menu_button = find_element(:css, ".interest-menu-button[data-property-reference='#{property.reference}']")
click(menu_button)
:timer.sleep(500)
{:ok, state}
end
and_ ~r/^I click "(?<option>[^"]+)" option$/, fn state, %{option: option} ->
option_button = find_element(:css, ".interest-option[data-status='#{String.downcase(option) |> String.replace(" ", "_")}']")
click(option_button)
:timer.sleep(500)
{:ok, state}
end
then_ ~r/^Property should move to bottom of the list$/, fn state ->
properties = find_all_elements(:css, "#properties > div")
last_property = List.last(properties)
property = List.first(state.advertisements)
title_element = find_within_element(last_property, :css, "h2")
title_text = visible_text(title_element)
assert title_text == property.title
{:ok, state}
end
then_ ~r/^Property should move to its original position$/, fn state ->
properties = find_all_elements(:css, "#properties > div")
first_property = List.first(properties)
property = List.first(state.advertisements)
title_element = find_within_element(first_property, :css, "h2")
title_text = visible_text(title_element)
assert title_text == property.title
{:ok, state}
end
then_ ~r/^I should see success message for marking as not interested$/, fn state ->
assert visible_in_page?(~r/Marked as not interested/i)
{:ok, state}
end
then_ ~r/^I should see success message for marking as interested$/, fn state ->
assert visible_in_page?(~r/Marked as interested/i)
{:ok, state}
end
then_ ~r/^I should not see interest menu 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, ".interest-menu-button[data-property-reference='#{property.reference}']")
assert Enum.empty?(elements),
"Expected no interest menu for property '#{argument_one}' but found #{length(elements)} menu(s)"
{:ok, state}
end
# Add these to ManagePropertyInterestContext
and_ ~r/^Property should be added to database$/, fn state ->
property = List.first(state.advertisements)
current_user = Repo.get_by(User, email: state[:random_email])
not_interested = Repo.one(from n in NotInterested,
where: n.user_id == ^current_user.id and n.property_id == ^property.id)
assert not_interested != nil,
"Expected property to be marked as not interested in database"
navigate_to("/logout")
{:ok, state}
end
and_ ~r/^Property should be deleted from database$/, fn state ->
property = List.first(state.advertisements)
current_user = Repo.get_by(User, email: state[:random_email])
not_interested = Repo.one(from n in NotInterested,
where: n.user_id == ^current_user.id and n.property_id == ^property.id)
assert not_interested == nil,
"Expected property to be removed from not interested in database"
navigate_to("/logout")
{: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 Interest Management
Scenario: User can mark property as not interested
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 interest menu for "Really cool property"
And I click "Not interested" option
Then Property should move to bottom of the list
And I should see success message for marking as not interested
And Property should be added to database
Scenario: User can mark not interested property as interested again
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
And the property "Really cool property" is marked as not interested
When I visit the home page
And I click interest menu for "Really cool property"
And I click "Interested" option
Then Property should move to its original position
And I should see success message for marking as interested
And Property should be deleted from database
Scenario: User cannot mark own property as not interested
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 interest menu for "Really cool property"
Scenario: Unauthenticated user cannot mark property as not interested
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 |
When I visit the home page
Then I should not see interest menu for "Really cool property"
\ No newline at end of file
defmodule PropTrackr.NotInterested do
use Ecto.Schema
import Ecto.Changeset
schema "not_interested" do
belongs_to :user, PropTrackr.Accounts.User
belongs_to :property, PropTrackr.Properties.Property
timestamps()
end
def changeset(not_interested, attrs \\ %{}) do
not_interested
|> cast(attrs, [:user_id, :property_id])
|> validate_required([:user_id, :property_id])
|> unique_constraint([:user_id, :property_id])
end
end
defmodule PropTrackrWeb.InterestController do
use PropTrackrWeb, :controller
import Ecto.Query
alias PropTrackr.Repo
alias PropTrackr.Properties.Property
alias PropTrackr.NotInterested
def create(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 ->
if property.user_id == current_user.id do
conn
|> put_status(:forbidden)
|> json(%{error: "Cannot mark own property as not interested"})
else
changeset = %NotInterested{}
|> NotInterested.changeset(%{
user_id: current_user.id,
property_id: property.id
})
case Repo.insert(changeset) do
{:ok, _} -> json(conn, %{message: "Marked as not interested"})
{:error, _} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "Already marked as not interested"})
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 ->
case Repo.one(from n in NotInterested,
where: n.user_id == ^current_user.id and n.property_id == ^property.id) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Already marked as interested"})
not_interested ->
case Repo.delete(not_interested) do
{:ok, _} -> json(conn, %{message: "Marked as interested"})
{:error, _} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to update"})
end
end
end
end
end
end
...@@ -6,16 +6,28 @@ defmodule PropTrackrWeb.PropertiesController do ...@@ -6,16 +6,28 @@ defmodule PropTrackrWeb.PropertiesController do
alias PropTrackr.Properties.{Property, Photo} alias PropTrackr.Properties.{Property, Photo}
alias PropTrackr.Favorites.Favorite alias PropTrackr.Favorites.Favorite
alias PropTrackr.Uploads alias PropTrackr.Uploads
alias PropTrackr.NotInterested
def index(conn, _params) do def index(conn, _params) do
current_user = conn.assigns.current_user
properties = Repo.all( current_user_id = current_user && current_user.id
from p in Property,
where: p.state == :available, properties = from(p in Property,
order_by: [asc: p.inserted_at], left_join: ni in NotInterested,
preload: [:photos], on: ni.property_id == p.id and ni.user_id == ^(current_user_id || 0),
# preload: [photos: ^from(ph in Photo, order_by: [asc: ph.order])], where: p.state == :available,
select: p) order_by: [asc: not is_nil(ni.id), asc: p.inserted_at],
preload: [:photos],
select: p)
|> Repo.all()
not_interested = if current_user do
Repo.all(from n in NotInterested,
where: n.user_id == ^current_user.id,
select: n.property_id)
else
[]
end
favorites = case conn.assigns.current_user do favorites = case conn.assigns.current_user do
nil -> [] nil -> []
...@@ -29,7 +41,8 @@ defmodule PropTrackrWeb.PropertiesController do ...@@ -29,7 +41,8 @@ defmodule PropTrackrWeb.PropertiesController do
render(conn, "index.html", render(conn, "index.html",
properties: properties, properties: properties,
favorites: favorites favorites: favorites,
not_interested: not_interested
) )
end end
......
...@@ -37,14 +37,35 @@ ...@@ -37,14 +37,35 @@
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<h2 class="font-bold"><%= property.title %></h2> <h2 class="font-bold"><%= property.title %></h2>
<%= if @conn.assigns[:current_user] && @conn.assigns.current_user.id != property.user_id do %> <%= if @conn.assigns[:current_user] && @conn.assigns.current_user.id != property.user_id do %>
<button <div class="flex items-center gap-4">
type="button" <button
class="favorite-button text-2xl" type="button"
data-property-reference={property.reference} class="favorite-button text-2xl"
data-favorited={if property.id in @favorites, do: "true", else: "false"} data-property-reference={property.reference}
> data-favorited={if property.id in @favorites, do: "true", else: "false"}
<%= if property.id in @favorites, do: "★", else: "☆" %> >
</button> <%= if property.id in @favorites, do: "★", else: "☆" %>
</button>
<div class="relative">
<button type="button" class="interest-menu-button" data-property-reference={property.reference}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
</button>
<div class="interest-menu hidden absolute right-0 mt-2 bg-white border rounded shadow-lg z-10">
<div class="py-1">
<button class="interest-option w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center gap-2" data-status="interested">
<span class={"interest-tick #{if property.id in @not_interested, do: "invisible", else: "visible"}"}></span>
Interested
</button>
<button class="interest-option w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center gap-2 whitespace-nowrap" data-status="not_interested">
<span class={"interest-tick #{if property.id in @not_interested, do: "visible", else: "invisible"}"}></span>
Not interested
</button>
</div>
</div>
</div>
</div>
<% end %> <% end %>
</div> </div>
<p><%= property.description %></p> <p><%= property.description %></p>
......
...@@ -54,6 +54,8 @@ defmodule PropTrackrWeb.Router do ...@@ -54,6 +54,8 @@ defmodule PropTrackrWeb.Router do
post "/properties/:reference/favorite", FavoriteController, :create post "/properties/:reference/favorite", FavoriteController, :create
delete "/properties/:reference/favorite", FavoriteController, :delete delete "/properties/:reference/favorite", FavoriteController, :delete
post "/properties/:reference/status", PropertiesController, :update_status post "/properties/:reference/status", PropertiesController, :update_status
post "/properties/:reference/interest", InterestController, :create
delete "/properties/:reference/interest", InterestController, :delete
end end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.
......
defmodule PropTrackr.Repo.Migrations.AddNotInterested do
use Ecto.Migration
def change do
create table(:not_interested) do
add :user_id, references(:users, on_delete: :delete_all)
add :property_id, references(:properties, on_delete: :delete_all)
timestamps()
end
create unique_index(:not_interested, [:user_id, :property_id])
end
end
defmodule PropTrackrWeb.InterestControllerTest do
use PropTrackrWeb.ConnCase
alias PropTrackr.Accounts.User
alias PropTrackr.Properties.Property
alias PropTrackr.NotInterested
alias PropTrackr.Repo
import Ecto.Query
setup do
owner = %User{
name: "Property", surname: "Owner",
birth_date: "2000-01-01", phone_number: "111",
email: "property.owner@gmail.com",
password: "password", confirm_password: "password"
} |> Repo.insert!()
random_user = %User{
name: "Random", surname: "User",
birth_date: "2000-01-01", phone_number: "000",
email: "random.user@gmail.com",
password: "password", confirm_password: "password"
} |> Repo.insert!()
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
} |> Repo.insert!()
{:ok, %{owner: owner, random_user: random_user, property: property}}
end
test "authenticated user can mark property as not interested", %{conn: conn, random_user: user, property: property} do
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/properties/#{property.reference}/interest")
assert json_response(conn, 200)["message"] == "Marked as not interested"
not_interested = Repo.one(from n in NotInterested,
where: n.user_id == ^user.id and n.property_id == ^property.id)
assert not_interested != nil
end
test "authenticated user cannot mark same property as not interested twice", %{conn: conn, random_user: user, property: property} do
Repo.insert!(%NotInterested{user_id: user.id, property_id: property.id})
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/properties/#{property.reference}/interest")
assert json_response(conn, 422)["error"] == "Already marked as not interested"
count = Repo.one(from n in NotInterested,
where: n.user_id == ^user.id and n.property_id == ^property.id,
select: count(n.id))
assert count == 1
end
test "owner cannot mark own property as not interested", %{conn: conn, owner: owner, property: property} do
conn = conn |> setup_session(owner)
conn = post(conn, ~p"/api/properties/#{property.reference}/interest")
assert json_response(conn, 403)["error"] == "Cannot mark own property as not interested"
not_interested = Repo.one(from n in NotInterested,
where: n.user_id == ^owner.id and n.property_id == ^property.id)
assert not_interested == nil
end
test "unauthenticated user cannot mark property as not interested", %{conn: conn, property: property} do
initial_count = Repo.one(from n in NotInterested, select: count(n.id))
conn = post(conn, ~p"/api/properties/#{property.reference}/interest")
assert json_response(conn, 401)["error"] == "You must be logged in"
final_count = Repo.one(from n in NotInterested, select: count(n.id))
assert initial_count == final_count
end
test "cannot mark non-existent property as not interested", %{conn: conn, random_user: user} do
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/properties/non-existent/interest")
assert json_response(conn, 404)["error"] == "Property not found"
end
test "authenticated user can mark property as interested again", %{conn: conn, random_user: user, property: property} do
Repo.insert!(%NotInterested{user_id: user.id, property_id: property.id})
conn = conn |> setup_session(user)
conn = delete(conn, ~p"/api/properties/#{property.reference}/interest")
assert json_response(conn, 200)["message"] == "Marked as interested"
not_interested = Repo.one(from n in NotInterested,
where: n.user_id == ^user.id and n.property_id == ^property.id)
assert not_interested == nil
end
test "authenticated user cannot mark interested property as interested again", %{conn: conn, random_user: user, property: property} do
conn = conn |> setup_session(user)
conn = delete(conn, ~p"/api/properties/#{property.reference}/interest")
assert json_response(conn, 404)["error"] == "Already marked as interested"
end
test "unauthenticated user cannot mark property as interested", %{conn: conn, random_user: user, property: property} do
Repo.insert!(%NotInterested{user_id: user.id, property_id: property.id})
initial_count = Repo.one(from n in NotInterested, select: count(n.id))
conn = delete(conn, ~p"/api/properties/#{property.reference}/interest")
assert json_response(conn, 401)["error"] == "You must be logged in"
final_count = Repo.one(from n in NotInterested, select: count(n.id))
assert initial_count == final_count
end
test "deleting property removes associated not interested marks", %{conn: conn, random_user: user, property: property} do
Repo.insert!(%NotInterested{user_id: user.id, property_id: property.id})
Repo.delete!(property)
not_interested = Repo.one(from n in NotInterested,
where: n.user_id == ^user.id and n.property_id == ^property.id)
assert not_interested == nil
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