From 9ff3b30ca6c20d9a00813db2469e7da6c1c412cd Mon Sep 17 00:00:00 2001 From: Droid-An Date: Thu, 2 Oct 2025 19:23:07 +0100 Subject: [PATCH 1/5] added test to check unfollow button --- front-end/tests/profile.spec.mjs | 48 ++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/front-end/tests/profile.spec.mjs b/front-end/tests/profile.spec.mjs index a90f69d..6f06bde 100644 --- a/front-end/tests/profile.spec.mjs +++ b/front-end/tests/profile.spec.mjs @@ -1,8 +1,13 @@ -import {test, expect} from "@playwright/test"; -import {TIMELINE_USERNAMES_ELEMENTS_LOCATOR, loginAsSample, loginAsJustSomeGuy, waitForLocatorToHaveMatches} from "./test-utils.mjs"; +import { test, expect } from "@playwright/test"; +import { + TIMELINE_USERNAMES_ELEMENTS_LOCATOR, + loginAsSample, + loginAsJustSomeGuy, + waitForLocatorToHaveMatches, +} from "./test-utils.mjs"; test.describe("Profile View", () => { - test("shows own profile when logged in", async ({page}) => { + test("shows own profile when logged in", async ({ page }) => { // Given a profile view // When I am logged in as sample await loginAsSample(page); @@ -16,8 +21,13 @@ test.describe("Profile View", () => { page.locator("#profile-container header a[data-username]") ).toBeVisible(); - await waitForLocatorToHaveMatches(page, TIMELINE_USERNAMES_ELEMENTS_LOCATOR); - const postUsernames = new Set(await page.locator(TIMELINE_USERNAMES_ELEMENTS_LOCATOR).allInnerTexts()); + await waitForLocatorToHaveMatches( + page, + TIMELINE_USERNAMES_ELEMENTS_LOCATOR + ); + const postUsernames = new Set( + await page.locator(TIMELINE_USERNAMES_ELEMENTS_LOCATOR).allInnerTexts() + ); expect(postUsernames).toEqual(new Set(["sample"])); // And bloom form is not attached @@ -26,7 +36,7 @@ test.describe("Profile View", () => { ).not.toBeAttached(); }); - test("shows other user's profile with follow button", async ({page}) => { + test("shows other user's profile with follow button", async ({ page }) => { // Given I am logged in as AS await loginAsJustSomeGuy(page); // When I go to sample's profile @@ -43,4 +53,30 @@ test.describe("Profile View", () => { // And bloom form is not attached await expect(page.locator("#bloom-form-container form")).not.toBeAttached(); }); + + test("allows unfollowing a user from their profile", async ({ page }) => { + await signUp(page, "sample"); + await signUp(page, "AnotherUser"); + + // Given a profile component AnotherUser + // And I am logged in as sample + await loginAsSample(page); + await page.goto("/#/profile/AnotherUser"); + // And sample is following AS + await page.click('[data-action="follow"]'); + + // When I view the profile component for AnotherUser + // Then I should see a button labeled "Unfollow" + const unfollowButton = page.locator('[data-action="unfollow"]'); + await expect(unfollowButton).toBeVisible(); + + // When I click the "Unfollow" button + await unfollowButton.click(); + + // Then I should no longer be following AnotherUser + const followerCount = page.locator("[data-follower-count]"); + await expect(followerCount).toHaveText("0"); + // And the unfollow button is not visible + await expect(unfollowButton).toBe("hidden"); + }); }); From c1abf7793761501e082b5ce194d14589b18d3b01 Mon Sep 17 00:00:00 2001 From: Droid-An Date: Fri, 3 Oct 2025 16:08:28 +0100 Subject: [PATCH 2/5] added unfollow button to the profile --- front-end/components/profile.mjs | 30 ++- front-end/index.html | 421 +++++++++++++------------------ front-end/views/profile.mjs | 16 +- 3 files changed, 215 insertions(+), 252 deletions(-) diff --git a/front-end/components/profile.mjs b/front-end/components/profile.mjs index ec4f200..8cb7145 100644 --- a/front-end/components/profile.mjs +++ b/front-end/components/profile.mjs @@ -1,4 +1,4 @@ -import {apiService} from "../index.mjs"; +import { apiService } from "../index.mjs"; /** * Create a profile component @@ -6,7 +6,7 @@ import {apiService} from "../index.mjs"; * @param {Object} profileData - The profile data to display * @returns {DocumentFragment} - The profile UI */ -function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { +function createProfile(template, { profileData, whoToFollow, isLoggedIn }) { if (!template || !profileData) return; const profileElement = document .getElementById(template) @@ -19,7 +19,12 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { ); const followerCountEl = profileElement.querySelector("[data-follower-count]"); const followButtonEl = profileElement.querySelector("[data-action='follow']"); - const whoToFollowContainer = profileElement.querySelector(".profile__who-to-follow"); + const unfollowButtonEl = profileElement.querySelector( + "[data-action='unfollow']" + ); + const whoToFollowContainer = profileElement.querySelector( + ".profile__who-to-follow" + ); // Populate with data usernameEl.querySelector("h2").textContent = profileData.username || ""; usernameEl.setAttribute("href", `/profile/${profileData.username}`); @@ -27,14 +32,20 @@ function createProfile(template, {profileData, whoToFollow, isLoggedIn}) { followerCountEl.textContent = profileData.followers?.length || 0; followingCountEl.textContent = profileData.follows?.length || 0; followButtonEl.setAttribute("data-username", profileData.username || ""); + unfollowButtonEl.setAttribute("data-username", profileData.username || ""); followButtonEl.hidden = profileData.is_self || profileData.is_following; + unfollowButtonEl.hidden = profileData.is_self || !profileData.is_following; followButtonEl.addEventListener("click", handleFollow); + unfollowButtonEl.addEventListener("click", handleUnfollow); if (!isLoggedIn) { followButtonEl.style.display = "none"; + unfollowButtonEl.style.display = "none"; } if (whoToFollow.length > 0) { - const whoToFollowList = whoToFollowContainer.querySelector("[data-who-to-follow]"); + const whoToFollowList = whoToFollowContainer.querySelector( + "[data-who-to-follow]" + ); const whoToFollowTemplate = document.querySelector("#who-to-follow-chip"); for (const userToFollow of whoToFollow) { const wtfElement = whoToFollowTemplate.content.cloneNode(true); @@ -66,4 +77,13 @@ async function handleFollow(event) { await apiService.getWhoToFollow(); } -export {createProfile, handleFollow}; +async function handleUnfollow(event) { + const button = event.target; + const username = button.getAttribute("data-username"); + if (!username) return; + + await apiService.unfollowUser(username); + await apiService.getWhoToFollow(); +} + +export { createProfile, handleFollow, handleUnfollow }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..2c9690d 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,261 +1,200 @@ - - - - Purple Forest - - - -
-

- Purple Forest - PurpleForest -

-
- -
-
-
- -
-
-
-
-
-

This Legacy Code project is coursework from Code Your Future

-
-
- - - - - - - - + + + + + + + + - + + - - - + \ No newline at end of file diff --git a/front-end/views/profile.mjs b/front-end/views/profile.mjs index dd2b92a..a199fc2 100644 --- a/front-end/views/profile.mjs +++ b/front-end/views/profile.mjs @@ -1,4 +1,4 @@ -import {renderEach, renderOne, destroy} from "../lib/render.mjs"; +import { renderEach, renderOne, destroy } from "../lib/render.mjs"; import { apiService, state, @@ -7,10 +7,14 @@ import { getProfileContainer, getTimelineContainer, } from "../index.mjs"; -import {createLogin, handleLogin} from "../components/login.mjs"; -import {createLogout, handleLogout} from "../components/logout.mjs"; -import {createProfile, handleFollow} from "../components/profile.mjs"; -import {createBloom} from "../components/bloom.mjs"; +import { createLogin, handleLogin } from "../components/login.mjs"; +import { createLogout, handleLogout } from "../components/logout.mjs"; +import { + createProfile, + handleFollow, + handleUnfollow, +} from "../components/profile.mjs"; +import { createBloom } from "../components/bloom.mjs"; // Profile view - just this person's blooms and their profile function profileView(username) { @@ -63,4 +67,4 @@ function profileView(username) { } } -export {profileView}; +export { profileView }; From 7ebfd7f08ba7a4f6a9dc48a1c38ea78f2eb955ac Mon Sep 17 00:00:00 2001 From: Droid-An Date: Fri, 3 Oct 2025 18:42:02 +0100 Subject: [PATCH 3/5] wip --- backend/data/unfollow.py | 13 +++++++++++++ backend/endpoints.py | 24 ++++++++++++++++++++++++ backend/main.py | 2 ++ front-end/tests/profile.spec.mjs | 8 +++++--- 4 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 backend/data/unfollow.py diff --git a/backend/data/unfollow.py b/backend/data/unfollow.py new file mode 100644 index 0000000..05366e0 --- /dev/null +++ b/backend/data/unfollow.py @@ -0,0 +1,13 @@ +from data.connection import db_cursor +from data.users import User + + +def unfollow(follower: User, followee: User): + with db_cursor() as cur: + cur.execute( + "DELETE FROM follows WHERE follower = %(follower_id)s AND followee = %(followee_id)s", + dict( + follower_id=follower.id, + followee_id=followee.id, + ), + ) diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..59061e1 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -1,6 +1,7 @@ from typing import Dict, Union from data import blooms from data.follows import follow, get_followed_usernames, get_inverse_followed_usernames +from data.unfollow import unfollow from data.users import ( UserRegistrationError, get_suggested_follows, @@ -150,6 +151,29 @@ def do_follow(): ) +@jwt_required() +def do_unfollow(): + type_check_error = verify_request_fields({"unfollow_username": str}) + if type_check_error is not None: + return type_check_error + + current_user = get_current_user() + + unfollow_username = request.json["unfollow_username"] + unfollow_user = get_user(unfollow_username) + if unfollow_user is None: + return make_response( + (f"Cannot unfollow {unfollow_username} - user does not exist", 404) + ) + + unfollow(current_user, unfollow_user) + return jsonify( + { + "success": True, + } + ) + + @jwt_required() def send_bloom(): type_check_error = verify_request_fields({"content": str}) diff --git a/backend/main.py b/backend/main.py index 7ba155f..a437812 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from data.users import lookup_user from endpoints import ( do_follow, + do_unfollow, get_bloom, hashtag, home_timeline, @@ -54,6 +55,7 @@ def main(): app.add_url_rule("/profile", view_func=self_profile) app.add_url_rule("/profile/", view_func=other_profile) app.add_url_rule("/follow", methods=["POST"], view_func=do_follow) + app.add_url_rule("/unfollow", methods=["POST"], view_func=do_unfollow) app.add_url_rule("/suggested-follows/", view_func=suggested_follows) app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) diff --git a/front-end/tests/profile.spec.mjs b/front-end/tests/profile.spec.mjs index 6f06bde..86fc773 100644 --- a/front-end/tests/profile.spec.mjs +++ b/front-end/tests/profile.spec.mjs @@ -4,6 +4,8 @@ import { loginAsSample, loginAsJustSomeGuy, waitForLocatorToHaveMatches, + signUp, + logout, } from "./test-utils.mjs"; test.describe("Profile View", () => { @@ -55,13 +57,13 @@ test.describe("Profile View", () => { }); test("allows unfollowing a user from their profile", async ({ page }) => { - await signUp(page, "sample"); + // await signUp(page, "sample"); await signUp(page, "AnotherUser"); - + await logout(page); // Given a profile component AnotherUser // And I am logged in as sample await loginAsSample(page); - await page.goto("/#/profile/AnotherUser"); + await page.goto("/#/profile/AS"); // And sample is following AS await page.click('[data-action="follow"]'); From 9da9e9383ef4c44cdb8f767123b8577afcdad7da Mon Sep 17 00:00:00 2001 From: Droid-An Date: Fri, 3 Oct 2025 18:50:44 +0100 Subject: [PATCH 4/5] wip return unfollow test to default --- front-end/tests/profile.spec.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front-end/tests/profile.spec.mjs b/front-end/tests/profile.spec.mjs index 86fc773..00319af 100644 --- a/front-end/tests/profile.spec.mjs +++ b/front-end/tests/profile.spec.mjs @@ -57,9 +57,9 @@ test.describe("Profile View", () => { }); test("allows unfollowing a user from their profile", async ({ page }) => { - // await signUp(page, "sample"); + await signUp(page, "sample"); await signUp(page, "AnotherUser"); - await logout(page); + // await logout(page); // Given a profile component AnotherUser // And I am logged in as sample await loginAsSample(page); From 5d20e79d2def95ecacb1772fc80d4b4c42e31d76 Mon Sep 17 00:00:00 2001 From: Droid-An Date: Sun, 5 Oct 2025 10:40:08 +0100 Subject: [PATCH 5/5] implemented unfollow functionality and fixed tests --- front-end/lib/api.mjs | 63 ++++++++++++++++---------------- front-end/tests/profile.spec.mjs | 7 +--- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..d5146b5 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -1,5 +1,5 @@ -import {state} from "../index.mjs"; -import {handleErrorDialog} from "../components/error.mjs"; +import { state } from "../index.mjs"; +import { handleErrorDialog } from "../components/error.mjs"; // === ABOUT THE STATE // state gives you these two functions only @@ -20,13 +20,13 @@ async function _apiRequest(endpoint, options = {}) { const defaultOptions = { headers: { "Content-Type": "application/json", - ...(token ? {Authorization: `Bearer ${token}`} : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, mode: "cors", credentials: "include", }; - const fetchOptions = {...defaultOptions, ...options}; + const fetchOptions = { ...defaultOptions, ...options }; const url = endpoint.startsWith("http") ? endpoint : `${baseUrl}${endpoint}`; try { @@ -54,7 +54,7 @@ async function _apiRequest(endpoint, options = {}) { const contentType = response.headers.get("content-type"); return contentType?.includes("application/json") ? await response.json() - : {success: true}; + : { success: true }; } catch (error) { if (!error.status) { // Only handle network errors here, response errors are handled above @@ -70,11 +70,11 @@ function _updateProfile(username, profileData) { const index = profiles.findIndex((p) => p.username === username); if (index !== -1) { - profiles[index] = {...profiles[index], ...profileData}; + profiles[index] = { ...profiles[index], ...profileData }; } else { - profiles.push({username, ...profileData}); + profiles.push({ username, ...profileData }); } - state.updateState({profiles}); + state.updateState({ profiles }); } // ====== AUTH methods @@ -82,7 +82,7 @@ async function login(username, password) { try { const data = await _apiRequest("/login", { method: "POST", - body: JSON.stringify({username, password}), + body: JSON.stringify({ username, password }), }); if (data.success && data.token) { @@ -96,7 +96,7 @@ async function login(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -104,12 +104,12 @@ async function getWhoToFollow() { try { const usernamesToFollow = await _apiRequest("/suggested-follows/3"); - state.updateState({whoToFollow: usernamesToFollow}); + state.updateState({ whoToFollow: usernamesToFollow }); return usernamesToFollow; } catch (error) { // Error already handled by _apiRequest - state.updateState({usernamesToFollow: []}); + state.updateState({ usernamesToFollow: [] }); return []; } } @@ -118,7 +118,7 @@ async function signup(username, password) { try { const data = await _apiRequest("/register", { method: "POST", - body: JSON.stringify({username, password}), + body: JSON.stringify({ username, password }), }); if (data.success && data.token) { @@ -132,20 +132,20 @@ async function signup(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } function logout() { state.destroyState(); - return {success: true}; + return { success: true }; } // ===== BLOOM methods async function getBloom(bloomId) { const endpoint = `/bloom/${bloomId}`; const bloom = await _apiRequest(endpoint); - state.updateState({singleBloomToShow: bloom}); + state.updateState({ singleBloomToShow: bloom }); return bloom; } @@ -156,18 +156,18 @@ async function getBlooms(username) { const blooms = await _apiRequest(endpoint); if (username) { - _updateProfile(username, {blooms}); + _updateProfile(username, { blooms }); } else { - state.updateState({timelineBlooms: blooms}); + state.updateState({ timelineBlooms: blooms }); } return blooms; } catch (error) { // Error already handled by _apiRequest if (username) { - _updateProfile(username, {blooms: []}); + _updateProfile(username, { blooms: [] }); } else { - state.updateState({timelineBlooms: []}); + state.updateState({ timelineBlooms: [] }); } return []; } @@ -189,7 +189,7 @@ async function getBloomsByHashtag(hashtag) { return blooms; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -197,7 +197,7 @@ async function postBloom(content) { try { const data = await _apiRequest("/bloom", { method: "POST", - body: JSON.stringify({content}), + body: JSON.stringify({ content }), }); if (data.success) { @@ -208,7 +208,7 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -225,16 +225,16 @@ async function getProfile(username) { const currentUsername = profileData.username; const fullProfileData = await _apiRequest(`/profile/${currentUsername}`); _updateProfile(currentUsername, fullProfileData); - state.updateState({currentUser: currentUsername, isLoggedIn: true}); + state.updateState({ currentUser: currentUsername, isLoggedIn: true }); } return profileData; } catch (error) { // Error already handled by _apiRequest if (!username) { - state.updateState({isLoggedIn: false, currentUser: null}); + state.updateState({ isLoggedIn: false, currentUser: null }); } - return {success: false}; + return { success: false }; } } @@ -242,7 +242,7 @@ async function followUser(username) { try { const data = await _apiRequest("/follow", { method: "POST", - body: JSON.stringify({follow_username: username}), + body: JSON.stringify({ follow_username: username }), }); if (data.success) { @@ -255,14 +255,15 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } async function unfollowUser(username) { try { - const data = await _apiRequest(`/unfollow/${username}`, { + const data = await _apiRequest("/unfollow", { method: "POST", + body: JSON.stringify({ unfollow_username: username }), }); if (data.success) { @@ -277,7 +278,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -300,4 +301,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService }; diff --git a/front-end/tests/profile.spec.mjs b/front-end/tests/profile.spec.mjs index 00319af..9909892 100644 --- a/front-end/tests/profile.spec.mjs +++ b/front-end/tests/profile.spec.mjs @@ -5,7 +5,6 @@ import { loginAsJustSomeGuy, waitForLocatorToHaveMatches, signUp, - logout, } from "./test-utils.mjs"; test.describe("Profile View", () => { @@ -57,13 +56,11 @@ test.describe("Profile View", () => { }); test("allows unfollowing a user from their profile", async ({ page }) => { - await signUp(page, "sample"); await signUp(page, "AnotherUser"); - // await logout(page); // Given a profile component AnotherUser // And I am logged in as sample await loginAsSample(page); - await page.goto("/#/profile/AS"); + await page.goto("/#/profile/AnotherUser"); // And sample is following AS await page.click('[data-action="follow"]'); @@ -79,6 +76,6 @@ test.describe("Profile View", () => { const followerCount = page.locator("[data-follower-count]"); await expect(followerCount).toHaveText("0"); // And the unfollow button is not visible - await expect(unfollowButton).toBe("hidden"); + await expect(unfollowButton).toBeHidden(); }); });