Skip to content
Snippets Groups Projects
Commit 912c0f9d authored by shyngys's avatar shyngys
Browse files

Merge branch '31-fr-21-dark-light-mode-appearance-switch-fix' into 'main'

Implemented dark mode, added TDD, fixed BDD

Closes #31

See merge request !44
parents 7ef22410 4db0a02a
No related branches found
No related tags found
1 merge request!44Implemented dark mode, added TDD, fixed BDD
Pipeline #46935 passed
const initTheme = () => { const initTheme = () => {
// Check for saved theme preference or default to system preference // Check if user is logged in
const savedTheme = localStorage.getItem('theme') || if (window.currentUser) {
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); // For logged-in users, use their database preference
const userDarkMode = window.userDarkMode === true;
// Apply initial theme applyTheme(userDarkMode ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', savedTheme); } else {
if (savedTheme === 'dark') { // For non-authenticated users, use localStorage
const savedTheme = localStorage.getItem('theme') || 'light';
applyTheme(savedTheme);
}
// Helper function to apply theme
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
} }
localStorage.setItem('theme', theme);
// Add click handler to theme toggle button }
document.addEventListener('click', (e) => {
const themeToggle = e.target.closest('[data-theme-toggle]'); // Add click handler to theme toggle button
if (themeToggle) { document.addEventListener('click', async (e) => {
const currentTheme = localStorage.getItem('theme') || 'light'; const themeToggle = e.target.closest('[data-theme-toggle]');
const newTheme = currentTheme === 'light' ? 'dark' : 'light'; if (themeToggle) {
const isDark = !document.documentElement.classList.contains('dark');
// Update localStorage const newTheme = isDark ? 'dark' : 'light';
localStorage.setItem('theme', newTheme);
// Apply theme change
// Update document classes and data attributes applyTheme(newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
if (newTheme === 'dark') { // If user is logged in, save preference to server
document.documentElement.classList.add('dark'); if (window.currentUser) {
} else { try {
document.documentElement.classList.remove('dark'); await fetch('/api/update_theme', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector("meta[name='csrf-token']").content
},
credentials: 'same-origin',
body: JSON.stringify({ is_dark_mode: isDark })
});
} catch (error) {
console.error('Error updating theme preference:', error);
} }
} }
}); }
});
// Listen for system theme changes };
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', e => { export default initTheme;
if (!localStorage.getItem('theme')) { \ No newline at end of file
const newTheme = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
});
};
export default initTheme;
\ No newline at end of file
...@@ -19,11 +19,18 @@ defmodule DarkModeContext do ...@@ -19,11 +19,18 @@ defmodule DarkModeContext do
given_ ~r/^there exists following accounts$/, fn state, %{table_data: table} -> given_ ~r/^there exists following accounts$/, fn state, %{table_data: table} ->
table table
|> Enum.map(fn user_details -> User.changeset(%User{}, user_details) end) |> Enum.map(fn user_details ->
# Handle is_dark_mode if present
user_details = case Map.get(user_details, :is_dark_mode) do
nil -> user_details
value when is_binary(value) -> Map.put(user_details, :is_dark_mode, value == "true")
_ -> user_details
end
User.changeset(%User{}, user_details)
end)
|> Enum.each(fn changeset -> Repo.insert!(changeset) end) |> Enum.each(fn changeset -> Repo.insert!(changeset) end)
user = List.last(table) user = List.last(table)
{ {
:ok, :ok,
state state
...@@ -93,23 +100,27 @@ defmodule DarkModeContext do ...@@ -93,23 +100,27 @@ defmodule DarkModeContext do
when_ ~r/^I visit the property details page$/, fn state -> when_ ~r/^I visit the property details page$/, fn state ->
property = hd(state.advertisements) property = hd(state.advertisements)
navigate_to("/properties/#{property.reference}") navigate_to("/properties/#{property.reference}")
:timer.sleep(1000) :timer.sleep(500)
{:ok, state} {:ok, state}
end end
# Light mode assertions # Light mode assertions
then_ ~r/^I should see the application in light mode$/, fn state -> then_ ~r/^I should see the application in light mode$/, fn state ->
assert get_css_variable("--color-background") == "#ffffff" :timer.sleep(500)
assert get_css_variable("--color-text") == "#1a1a1a" html_classes = find_element(:css, "html") |> attribute_value("class")
assert get_css_variable("--color-card") == "#ffffff" refute String.contains?(html_classes, "dark")
assert has_classes?("body", ["bg-white", "text-zinc-900"])
assert element_displayed?(find_element(:css, ".theme-toggle-button .hero-moon-solid"))
{:ok, state} {:ok, state}
end end
# Dark mode assertions # Dark mode assertions
then_ ~r/^I should see the application in dark mode$/, fn state -> then_ ~r/^I should see the application in dark mode$/, fn state ->
assert get_css_variable("--color-background") == "#0f172a" :timer.sleep(500)
assert get_css_variable("--color-text") == "#e2e8f0" html_classes = find_element(:css, "html") |> attribute_value("class")
assert get_css_variable("--color-card") == "#1e293b" assert String.contains?(html_classes, "dark")
assert has_classes?("body", ["dark:bg-dark-background", "dark:text-dark-text"])
assert element_displayed?(find_element(:css, ".theme-toggle-button .hero-sun-solid"))
{:ok, state} {:ok, state}
end end
...@@ -123,7 +134,8 @@ defmodule DarkModeContext do ...@@ -123,7 +134,8 @@ defmodule DarkModeContext do
end end
then_ ~r/^the background should be dark$/, fn state -> then_ ~r/^the background should be dark$/, fn state ->
assert get_css_variable("--color-background") == "#0f172a" :timer.sleep(500)
assert has_classes?("body", ["dark:bg-dark-background"])
{:ok, state} {:ok, state}
end end
...@@ -146,7 +158,8 @@ defmodule DarkModeContext do ...@@ -146,7 +158,8 @@ defmodule DarkModeContext do
end end
then_ ~r/^the text should be light colored$/, fn state -> then_ ~r/^the text should be light colored$/, fn state ->
assert get_css_variable("--color-text") == "#e2e8f0" :timer.sleep(500)
assert has_classes?("body", ["dark:text-dark-text"]) # Using the actual class from your HTML
{:ok, state} {:ok, state}
end end
...@@ -171,17 +184,30 @@ defmodule DarkModeContext do ...@@ -171,17 +184,30 @@ defmodule DarkModeContext do
end))} end))}
end end
defp setup_session(email, password) do
fill_field({:id, "email"}, email)
fill_field({:id, "password"}, password)
click({:id, "login_button"})
end
defp get_css_variable(variable_name) do defp get_css_variable(variable_name) do
script = """ script = """
var style = getComputedStyle(document.documentElement); var root = document.documentElement;
return style.getPropertyValue('#{variable_name}').trim(); var theme = root.getAttribute('data-theme');
var style = getComputedStyle(root);
var value = style.getPropertyValue('#{variable_name}').trim();
console.log('Theme:', theme, 'Variable:', '#{variable_name}', 'Value:', value);
return value;
""" """
execute_script(script) execute_script(script)
end end
defp setup_session(email, password) do defp get_element_classes(selector) do
fill_field({:id, "email"}, email) find_element(:css, selector) |> attribute_value("class")
fill_field({:id, "password"}, password) end
click({:id, "login_button"})
defp has_classes?(selector, classes) when is_list(classes) do
element_classes = get_element_classes(selector)
Enum.all?(classes, &String.contains?(element_classes, &1))
end end
end end
...@@ -13,18 +13,29 @@ Feature: FR-21: Dark/Light mode appearance switch ...@@ -13,18 +13,29 @@ Feature: FR-21: Dark/Light mode appearance switch
And the background should be dark And the background should be dark
And the text should be light colored And the text should be light colored
Scenario: Dark mode persists across pages Scenario: Dark mode user preference persists across pages
Given there exists following accounts Given there exists following accounts
| name | surname | birth_date | phone_number | email | password | confirm_password | | name | surname | birth_date | phone_number | email | password | confirm_password | is_dark_mode |
| Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | | Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | true |
And I have enabled dark mode
When I navigate to the login page When I navigate to the login page
Then I should see the login page in dark mode
And I am logged in And I am logged in
Then I should see the application in dark mode
When I navigate to the profile page When I navigate to the profile page
Then I should see the profile page in dark mode Then I should see the application in dark mode
When I navigate to the home page When I navigate to the home page
Then I should see the home page in dark mode Then I should see the application in dark mode
Scenario: Light mode user preference persists across pages
Given there exists following accounts
| name | surname | birth_date | phone_number | email | password | confirm_password | is_dark_mode |
| Existing | Account | 2000-01-01 | 000 | existing.account@gmail.com | password | password | false |
When I navigate to the login page
And I am logged in
Then I should see the application in light mode
When I navigate to the profile page
Then I should see the application in light mode
When I navigate to the home page
Then I should see the application in light mode
Scenario: Theme preference persists after refresh Scenario: Theme preference persists after refresh
Given I have enabled dark mode Given I have enabled dark mode
...@@ -43,6 +54,6 @@ Feature: FR-21: Dark/Light mode appearance switch ...@@ -43,6 +54,6 @@ Feature: FR-21: Dark/Light mode appearance switch
Then I should see dark mode colors for: Then I should see dark mode colors for:
| Element | Color | | Element | Color |
| Background | #0f172a | | Background | #0f172a |
# | Text | #e2e8f0 | | Text | #e2e8f0 |
# | Cards | #1e293b | | Cards | #1e293b |
# | Buttons | #60a5fa | | Buttons | #60a5fa |
\ No newline at end of file \ No newline at end of file
...@@ -14,6 +14,7 @@ defmodule PropTrackr.Accounts.User do ...@@ -14,6 +14,7 @@ defmodule PropTrackr.Accounts.User do
field :email, :string field :email, :string
field :password, :string field :password, :string
field :confirm_password, :string, virtual: true # Define confirm_password as virtual, not includes in database field :confirm_password, :string, virtual: true # Define confirm_password as virtual, not includes in database
field :is_dark_mode, :boolean, default: false
has_many :properties, PropTrackr.Properties.Property has_many :properties, PropTrackr.Properties.Property
has_many :searches, PropTrackr.Search has_many :searches, PropTrackr.Search
...@@ -23,7 +24,7 @@ defmodule PropTrackr.Accounts.User do ...@@ -23,7 +24,7 @@ defmodule PropTrackr.Accounts.User do
def changeset(struct, params \\ %{}) do def changeset(struct, params \\ %{}) do
struct struct
|> cast(params, [:name, :surname, :birth_date, :phone_number, :bio, :email, :password, :confirm_password]) |> cast(params, [:name, :surname, :birth_date, :phone_number, :bio, :email, :password, :confirm_password, :is_dark_mode])
|> validate_required([:name, :surname, :birth_date, :phone_number, :email, :password, :confirm_password]) |> validate_required([:name, :surname, :birth_date, :phone_number, :email, :password, :confirm_password])
|> validate_format(:name, ~r/^[a-zA-Z\s-]+$/, message: "must contain only English letters, spaces, or hyphens") |> validate_format(:name, ~r/^[a-zA-Z\s-]+$/, message: "must contain only English letters, spaces, or hyphens")
|> validate_format(:surname, ~r/^[a-zA-Z\s-]+$/, message: "must contain only English letters, spaces, or hyphens") |> validate_format(:surname, ~r/^[a-zA-Z\s-]+$/, message: "must contain only English letters, spaces, or hyphens")
...@@ -96,4 +97,10 @@ defmodule PropTrackr.Accounts.User do ...@@ -96,4 +97,10 @@ defmodule PropTrackr.Accounts.User do
end end
end end
def update_theme_preference(user, is_dark) do
user
|> cast(%{is_dark_mode: is_dark}, [:is_dark_mode])
|> PropTrackr.Repo.update()
end
end end
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable] dark:dark" data-theme="light"> <html lang="en"
class={
cond do
assigns[:current_user] && assigns[:current_user].is_dark_mode -> "[scrollbar-gutter:stable] dark"
assigns[:current_user] -> "[scrollbar-gutter:stable]"
true -> "[scrollbar-gutter:stable]" # For non-authenticated users, initial class will be set by JS
end
}>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
...@@ -8,10 +15,16 @@ ...@@ -8,10 +15,16 @@
<%= assigns[:page_title] || "PropTrackr" %> <%= assigns[:page_title] || "PropTrackr" %>
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script>
window.currentUser = <%= if assigns[:current_user], do: "true", else: "false" %>;
window.userDarkMode = <%= if assigns[:current_user], do: "#{assigns[:current_user].is_dark_mode}", else: "null" %>;
</script>
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script> </script>
</head> </head>
<body class="bg-white dark:bg-dark-background text-zinc-900 dark:text-dark-text transition-colors duration-300"> <body class="bg-white dark:bg-dark-background text-zinc-900 dark:text-dark-text transition-colors duration-300">
<%= @inner_content %> <%= @inner_content %>
</body> </body>
</html> </html>
\ No newline at end of file
# lib/proptrackr_web/controllers/theme_controller.ex
defmodule PropTrackrWeb.ThemeController do
use PropTrackrWeb, :controller
alias PropTrackr.Accounts.User
alias PropTrackr.Repo
def update(conn, %{"is_dark_mode" => is_dark_mode}) do
case conn.assigns.current_user do
nil ->
conn
|> put_status(:unauthorized)
|> json(%{error: "User not authenticated"})
user ->
# Update user directly using Repo
case Repo.update(Ecto.Changeset.change(user, %{is_dark_mode: is_dark_mode})) do
{:ok, updated_user} ->
# Update session with new theme preference
conn
|> put_session(:user_theme, updated_user.is_dark_mode)
|> json(%{success: true, is_dark_mode: updated_user.is_dark_mode})
{:error, _changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "Could not update theme preference"})
end
end
end
end
...@@ -62,6 +62,7 @@ defmodule PropTrackrWeb.Router do ...@@ -62,6 +62,7 @@ defmodule PropTrackrWeb.Router do
post "/properties/calculate_price", PropertiesController, :calculate_price post "/properties/calculate_price", PropertiesController, :calculate_price
post "/properties/:reference/interest", InterestController, :create post "/properties/:reference/interest", InterestController, :create
delete "/properties/:reference/interest", InterestController, :delete delete "/properties/:reference/interest", InterestController, :delete
post "/update_theme", ThemeController, :update
end end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.
......
defmodule PropTrackr.Repo.Migrations.AddDarkModeToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :is_dark_mode, :boolean, default: false, null: false
end
end
end
defmodule PropTrackrWeb.ThemeControllerTest do
use PropTrackrWeb.ConnCase
alias PropTrackr.Accounts.User
alias PropTrackr.Repo
setup do
user = %User{
name: "Test",
surname: "User",
birth_date: ~D[2000-01-01],
phone_number: "000",
bio: "Yo",
email: "test.user@gmail.com",
password: "testing",
confirm_password: "testing",
is_dark_mode: false
}
user = Repo.insert!(user)
other_user = %User{
name: "Other",
surname: "User",
birth_date: ~D[2000-01-01],
phone_number: "111",
bio: "Hey",
email: "other.user@gmail.com",
password: "testing",
confirm_password: "testing",
is_dark_mode: true
}
other_user = Repo.insert!(other_user)
{:ok, %{user: user, other_user: other_user}}
end
test "authenticated user can enable dark mode", %{conn: conn, user: user} do
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true})
assert json_response(conn, 200) == %{
"success" => true,
"is_dark_mode" => true
}
updated_user = Repo.get!(User, user.id)
assert updated_user.is_dark_mode == true
end
test "authenticated user can disable dark mode", %{conn: conn, other_user: user} do
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => false})
assert json_response(conn, 200) == %{
"success" => true,
"is_dark_mode" => false
}
updated_user = Repo.get!(User, user.id)
assert updated_user.is_dark_mode == false
end
test "unauthenticated user cannot update theme", %{conn: conn} do
conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true})
assert json_response(conn, 401) == %{
"error" => "User not authenticated"
}
end
test "theme preference persists in session", %{conn: conn, user: user} do
conn = conn |> setup_session(user)
conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true})
assert get_session(conn, :user_theme) == true
updated_user = Repo.get!(User, user.id)
assert updated_user.is_dark_mode == true
end
test "user can toggle theme multiple times", %{conn: conn, user: user} do
conn = conn |> setup_session(user)
# Enable dark mode
conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true})
assert json_response(conn, 200) == %{"success" => true, "is_dark_mode" => true}
# Disable dark mode
conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => false})
assert json_response(conn, 200) == %{"success" => true, "is_dark_mode" => false}
# Enable dark mode again
conn = post(conn, ~p"/api/update_theme", %{"is_dark_mode" => true})
assert json_response(conn, 200) == %{"success" => true, "is_dark_mode" => true}
updated_user = Repo.get!(User, user.id)
assert updated_user.is_dark_mode == true
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