From cf2eefafb47f695069215698aa9b4bd928b0dc54 Mon Sep 17 00:00:00 2001 From: Droid-An Date: Mon, 6 Oct 2025 12:44:59 +0100 Subject: [PATCH 1/5] WIP: added share button on screen, adapted db to count rebloom, created rebloom endpoint in backend, added tests to check share button on page and new post visability to followers --- backend/README.md | 2 +- backend/data/blooms.py | 27 +- backend/endpoints.py | 13 + backend/main.py | 2 + db/schema.sql | 3 +- front-end/components/bloom.mjs | 21 +- front-end/index.html | 422 +++++++++++++------------------ front-end/lib/api.mjs | 73 +++--- front-end/tests/home.spec.mjs | 36 ++- front-end/tests/rebloom.spec.mjs | 47 ++++ front-end/tests/test-utils.mjs | 25 +- 11 files changed, 375 insertions(+), 296 deletions(-) create mode 100644 front-end/tests/rebloom.spec.mjs diff --git a/backend/README.md b/backend/README.md index 8e31e11..fe0b65a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -15,7 +15,7 @@ To run: 5. Run the database: `../db/run.sh` (you must have Docker installed and running). 6. Create the database schema: `../db/create-schema.sh` -You may want to run `python3 populate.py` to populate sample data. +You may want to run `python3 populate.py` to populate sample data.  If you ever need to wipe the database, just delete `../db/pg_data` (and remember to set it up again after). diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..4c7e362 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,6 +13,7 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + reblooms: int def add_bloom(*, sender: User, content: str) -> Bloom: @@ -22,12 +23,13 @@ def add_bloom(*, sender: User, content: str) -> Bloom: bloom_id = int(now.timestamp() * 1000000) with db_cursor() as cur: cur.execute( - "INSERT INTO blooms (id, sender_id, content, send_timestamp) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s)", + "INSERT INTO blooms (id, sender_id, content, send_timestamp, reblooms) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(reblooms)s)", dict( bloom_id=bloom_id, sender_id=sender.id, content=content, timestamp=datetime.datetime.now(datetime.UTC), + reblooms=0, ), ) for hashtag in hashtags: @@ -54,7 +56,7 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, reblooms FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -68,13 +70,14 @@ def get_blooms_for_user( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + bloom_id, sender_username, content, timestamp, reblooms = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, ) ) return blooms @@ -83,18 +86,19 @@ def get_blooms_for_user( def get_bloom(bloom_id: int) -> Optional[Bloom]: with db_cursor() as cur: cur.execute( - "SELECT blooms.id, users.username, content, send_timestamp FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", + "SELECT blooms.id, users.username, content, send_timestamp, reblooms FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", (bloom_id,), ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp = row + bloom_id, sender_username, content, timestamp, reblooms = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, ) @@ -108,7 +112,7 @@ def get_blooms_with_hashtag( with db_cursor() as cur: cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, reblooms FROM blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id WHERE @@ -121,18 +125,27 @@ def get_blooms_with_hashtag( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + bloom_id, sender_username, content, timestamp, reblooms = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + reblooms=reblooms, ) ) return blooms +def rebloom(bloom_id: int) -> None: + with db_cursor() as cur: + cur.execute( + "UPDATE blooms SET reblooms = reblooms + 1 WHERE blooms.id = %s", + (bloom_id,), + ) + + def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: if limit is not None: limit_clause = "LIMIT %(limit)s" diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..11e0769 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -212,6 +212,19 @@ def user_blooms(profile_username): return jsonify(user_blooms) +def rebloom(bloom_id): + try: + id_int = int(bloom_id) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.rebloom(id_int) + return jsonify( + { + "success": True, + } + ) + + @jwt_required() def suggested_follows(limit_str): try: diff --git a/backend/main.py b/backend/main.py index 7ba155f..03f1dca 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ send_bloom, suggested_follows, user_blooms, + rebloom, ) from dotenv import load_dotenv @@ -60,6 +61,7 @@ def main(): app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) + app.add_url_rule("/rebloom/", methods=["POST"], view_func=rebloom) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580..fde0a00 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10,7 +10,8 @@ CREATE TABLE blooms ( id BIGSERIAL NOT NULL PRIMARY KEY, sender_id INT NOT NULL REFERENCES users(id), content TEXT NOT NULL, - send_timestamp TIMESTAMP NOT NULL + send_timestamp TIMESTAMP NOT NULL, + reblooms INT NOT NULL DEFAULT 0 ); CREATE TABLE follows ( diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..e429e8b 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,3 +1,5 @@ +import { apiService } from "../index.mjs"; + /** * Create a bloom component * @param {string} template - The ID of the template to clone @@ -20,8 +22,11 @@ const createBloom = (template, bloom) => { const bloomTime = bloomFrag.querySelector("[data-time]"); const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])"); const bloomContent = bloomFrag.querySelector("[data-content]"); + const rebloomButtonEl = bloomFrag.querySelector( + "[data-action='share-bloom']" + ); + const rebloomCountEl = bloomFrag.querySelector("[rebloom-count]"); - bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); @@ -30,6 +35,10 @@ const createBloom = (template, bloom) => { ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") .body.childNodes ); + // redo to "bloom.reblooms || 0" once reblooms implemented to object + rebloomCountEl.textContent = bloom.reblooms ? bloom.reblooms : 0; + rebloomButtonEl.setAttribute("data-id", bloom.id || ""); + rebloomButtonEl.addEventListener("click", handleRebloom); return bloomFrag; }; @@ -84,4 +93,12 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +async function handleRebloom(event) { + const button = event.target; + const id = button.getAttribute("data-id"); + if (!id) return; + + await apiService.rebloom(id); +} + +export { createBloom, handleRebloom }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..bf7de9b 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,261 +1,201 @@ - - - - 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/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..9d1e033 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,10 +208,22 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } +async function rebloom(id) { + try { + const data = await _apiRequest(`/rebloom/${bloomId}`, { + method: "POST", + body: JSON.stringify({ id }), + }); + if (data.success) { + console.log(data); + } + } catch (error) {} +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -225,16 +237,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 +254,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,7 +267,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +289,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -292,6 +304,7 @@ const apiService = { getBlooms, postBloom, getBloomsByHashtag, + rebloom, // User methods getProfile, @@ -300,4 +313,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService }; diff --git a/front-end/tests/home.spec.mjs b/front-end/tests/home.spec.mjs index 0c9a4d4..86d84d8 100644 --- a/front-end/tests/home.spec.mjs +++ b/front-end/tests/home.spec.mjs @@ -1,8 +1,14 @@ -import {test, expect} from "@playwright/test"; -import {TIMELINE_USERNAMES_ELEMENTS_LOCATOR, loginAsSample, postBloom, logout, waitForLocatorToHaveMatches} from "./test-utils.mjs"; +import { test, expect } from "@playwright/test"; +import { + TIMELINE_USERNAMES_ELEMENTS_LOCATOR, + loginAsSample, + postBloom, + logout, + waitForLocatorToHaveMatches, +} from "./test-utils.mjs"; test.describe("Home View", () => { - test("shows login component when not logged in", async ({page}) => { + test("shows login component when not logged in", async ({ page }) => { // Given an index load await page.goto("/"); @@ -19,7 +25,7 @@ test.describe("Home View", () => { ).toBeVisible(); }); - test("shows core home components when logged in", async ({page}) => { + test("shows core home components when logged in", async ({ page }) => { // Given I am logged in await loginAsSample(page); @@ -31,18 +37,19 @@ test.describe("Home View", () => { await expect(page.locator('[data-form="bloom"]')).toBeVisible(); }); - test("shows timeline after creating a bloom", async ({page}) => { + test("shows timeline after creating a bloom", async ({ page }) => { // Given I am logged in await loginAsSample(page); - // When I create a bloom await postBloom(page, "My first bloom!"); // Then I see the bloom in the timeline - await expect(page.locator("[data-bloom] [data-content]").first()).toContainText("My first bloom!"); + await expect( + page.locator("[data-bloom] [data-content]").first() + ).toContainText("My first bloom!"); }); - test("hides components after logout", async ({page}) => { + test("hides components after logout", async ({ page }) => { // Given I am logged in await loginAsSample(page); @@ -59,12 +66,19 @@ test.describe("Home View", () => { await expect(page.locator('[data-form="bloom"]')).not.toBeAttached(); }); - test("shows own and followed user's posts in home timeline", async ({page}) => { + test("shows own and followed user's posts in home timeline", async ({ + page, + }) => { // Given I am logged in as sample who already follows JustSomeGuy await loginAsSample(page); - await waitForLocatorToHaveMatches(page, TIMELINE_USERNAMES_ELEMENTS_LOCATOR); - const postUsernames = await page.locator(TIMELINE_USERNAMES_ELEMENTS_LOCATOR).allInnerTexts(); + await waitForLocatorToHaveMatches( + page, + TIMELINE_USERNAMES_ELEMENTS_LOCATOR + ); + const postUsernames = await page + .locator(TIMELINE_USERNAMES_ELEMENTS_LOCATOR) + .allInnerTexts(); // Then I see my own posts in my timeline expect(postUsernames).toContain("sample"); // And I see my a followed user's posts in my timeline diff --git a/front-end/tests/rebloom.spec.mjs b/front-end/tests/rebloom.spec.mjs new file mode 100644 index 0000000..b1fde42 --- /dev/null +++ b/front-end/tests/rebloom.spec.mjs @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { + TIMELINE_USERNAMES_ELEMENTS_LOCATOR, + loginAsSample, + loginAsJustSomeGuy, + loginAsSwiz, + waitForLocatorToHaveMatches, + signUp, + logout, + postBloom, +} from "./test-utils.mjs"; + +test.describe("Rebloom functionality", () => { + test("'Share' button is visible", async ({ page }) => { + // Given I am logged in as sample + await loginAsSample(page); + // When I go to AS profile + await page.goto("/#/profile/AS"); + // Then I see a "Share "button + const shareButton = page.locator('[data-action="share-bloom"]'); + await expect(shareButton).toBeVisible(); + }); + + test("Follower see your new post in their's home view", async ({ page }) => { + // Given I am logged in as sample + await loginAsSample(page); + + // When I create a bloom + await postBloom(page, "My 666 bloom!"); + + // Then I see the bloom in the timeline + await expect( + page.locator("[data-bloom] [data-content]").first() + ).toContainText("My 666 bloom!"); + + // When I logout + await logout(page); + + // When I am logged in as Swiz who already follows Sample + await loginAsSwiz(page); + + // Then I see the sample's bloom in the timeline + await expect( + page.locator("[data-bloom] [data-content]").first() + ).toContainText("My 666 bloom!"); + }); +}); diff --git a/front-end/tests/test-utils.mjs b/front-end/tests/test-utils.mjs index 5651a15..6105b7f 100644 --- a/front-end/tests/test-utils.mjs +++ b/front-end/tests/test-utils.mjs @@ -2,7 +2,7 @@ * Common test actions for Playwright tests */ -import {expect} from "@playwright/test"; +import { expect } from "@playwright/test"; /** * Log in with sample credentials @@ -26,6 +26,20 @@ export async function loginAsJustSomeGuy(page) { await page.click('[data-form="login"] [data-submit]'); } +/** + * Log in with sample credentials + * @param {import('@playwright/test').Page} page + */ +export async function loginAsSwiz(page) { + await page.goto("/"); + await page.fill('[data-form="login"] input[name="username"]', "Swiz"); + await page.fill( + '[data-form="login"] input[name="password"]', + "singingalldayeveryday" + ); + await page.click('[data-form="login"] [data-submit]'); +} + /** * Sign up with generated credentials * @param {import('@playwright/test').Page} page @@ -48,7 +62,10 @@ export async function signUp(page, username) { * @param {string} content - Bloom content */ export async function postBloom(page, content) { + // Added timeouts here because tests try to fill textarea before the whole page loaded + await page.waitForTimeout(400); await page.fill('[data-form="bloom"] textarea[name="content"]', content); + await page.waitForTimeout(200); await page.click('[data-form="bloom"] [data-submit]'); } @@ -68,6 +85,8 @@ export function generateUsername() { return `testuser_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; } -export const TIMELINE_USERNAMES_ELEMENTS_LOCATOR = "#timeline-container article [data-username]"; +export const TIMELINE_USERNAMES_ELEMENTS_LOCATOR = + "#timeline-container article [data-username]"; -export const waitForLocatorToHaveMatches = async (page, locator) => await expect.poll(() => page.locator(locator).count()).toBeGreaterThan(0); +export const waitForLocatorToHaveMatches = async (page, locator) => + await expect.poll(() => page.locator(locator).count()).toBeGreaterThan(0); From 7507713bcd9fcde272a10cfee8cd510d4a52d227 Mon Sep 17 00:00:00 2001 From: Droid-An Date: Mon, 13 Oct 2025 14:33:23 +0100 Subject: [PATCH 2/5] implemented rebloom count functionality and written test to check it --- front-end/components/bloom.mjs | 15 +++++++++++---- front-end/lib/api.mjs | 6 +++--- front-end/tests/rebloom.spec.mjs | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index e429e8b..26b22e4 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,4 +1,4 @@ -import { apiService } from "../index.mjs"; +import { apiService, state } from "../index.mjs"; /** * Create a bloom component @@ -25,7 +25,7 @@ const createBloom = (template, bloom) => { const rebloomButtonEl = bloomFrag.querySelector( "[data-action='share-bloom']" ); - const rebloomCountEl = bloomFrag.querySelector("[rebloom-count]"); + const rebloomCountEl = bloomFrag.querySelector("[data-rebloom-count]"); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; @@ -36,7 +36,7 @@ const createBloom = (template, bloom) => { .body.childNodes ); // redo to "bloom.reblooms || 0" once reblooms implemented to object - rebloomCountEl.textContent = bloom.reblooms ? bloom.reblooms : 0; + rebloomCountEl.textContent = bloom.reblooms; rebloomButtonEl.setAttribute("data-id", bloom.id || ""); rebloomButtonEl.addEventListener("click", handleRebloom); @@ -97,8 +97,15 @@ async function handleRebloom(event) { const button = event.target; const id = button.getAttribute("data-id"); if (!id) return; - + addRebloomToFeed(id); await apiService.rebloom(id); } +async function addRebloomToFeed(id) { + const bloomContent = state.timelineBlooms.find( + (bloom) => bloom.id == id + ).content; + apiService.postBloom(bloomContent); +} + export { createBloom, handleRebloom }; diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index 9d1e033..a2c4eab 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -214,12 +214,12 @@ async function postBloom(content) { async function rebloom(id) { try { - const data = await _apiRequest(`/rebloom/${bloomId}`, { + const data = await _apiRequest(`/rebloom/${id}`, { method: "POST", - body: JSON.stringify({ id }), }); if (data.success) { - console.log(data); + await getBlooms(); + await getProfile(state.currentUser); } } catch (error) {} } diff --git a/front-end/tests/rebloom.spec.mjs b/front-end/tests/rebloom.spec.mjs index b1fde42..7e4c06a 100644 --- a/front-end/tests/rebloom.spec.mjs +++ b/front-end/tests/rebloom.spec.mjs @@ -44,4 +44,22 @@ test.describe("Rebloom functionality", () => { page.locator("[data-bloom] [data-content]").first() ).toContainText("My 666 bloom!"); }); + + test("Rebloom count updates, when click share", async ({ page }) => { + // Given I am logged in as sample + await loginAsSample(page); + + await postBloom(page, "My 666 bloom!"); + + await logout(page); + + // When I am logged in as Swiz who already follows Sample + await loginAsSwiz(page); + + await page.click(`[data-action="share-bloom"]`); + await page.waitForTimeout(200); + const rebloomCount = page.locator("[data-rebloom-count]").nth(1); + await page.waitForTimeout(200); + await expect(rebloomCount).toHaveText("1"); + }); }); From f62198c801ee6231f2de73e2f5ce15bfe29b7704 Mon Sep 17 00:00:00 2001 From: Droid-An Date: Mon, 13 Oct 2025 17:37:56 +0100 Subject: [PATCH 3/5] WIP: added new rebloom endpoint and added original id column to the db --- backend/data/blooms.py | 17 +++++++++++++---- backend/endpoints.py | 21 +++++++++++++++++++-- backend/main.py | 10 ++++++++-- db/schema.sql | 1 + front-end/components/bloom.mjs | 13 ++++--------- front-end/lib/api.mjs | 20 +++++++++++++++++--- 6 files changed, 62 insertions(+), 20 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 4c7e362..33d457a 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -14,6 +14,7 @@ class Bloom: content: str sent_timestamp: datetime.datetime reblooms: int + original_bloom_id: int def add_bloom(*, sender: User, content: str) -> Bloom: @@ -23,13 +24,14 @@ def add_bloom(*, sender: User, content: str) -> Bloom: bloom_id = int(now.timestamp() * 1000000) with db_cursor() as cur: cur.execute( - "INSERT INTO blooms (id, sender_id, content, send_timestamp, reblooms) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(reblooms)s)", + "INSERT INTO blooms (id, sender_id, content, send_timestamp, reblooms, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(reblooms)s,%(original_bloom_id)s)", dict( bloom_id=bloom_id, sender_id=sender.id, content=content, timestamp=datetime.datetime.now(datetime.UTC), reblooms=0, + original_bloom_id=None, ), ) for hashtag in hashtags: @@ -56,7 +58,7 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp, reblooms + blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -78,6 +80,7 @@ def get_blooms_for_user( content=content, sent_timestamp=timestamp, reblooms=reblooms, + original_bloom_id=None, ) ) return blooms @@ -99,6 +102,7 @@ def get_bloom(bloom_id: int) -> Optional[Bloom]: content=content, sent_timestamp=timestamp, reblooms=reblooms, + original_bloom_id=None, ) @@ -112,7 +116,7 @@ def get_blooms_with_hashtag( with db_cursor() as cur: cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp, reblooms + blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id WHERE @@ -133,12 +137,13 @@ def get_blooms_with_hashtag( content=content, sent_timestamp=timestamp, reblooms=reblooms, + original_bloom_id=None, ) ) return blooms -def rebloom(bloom_id: int) -> None: +def update_rebloom_counter(bloom_id: int) -> None: with db_cursor() as cur: cur.execute( "UPDATE blooms SET reblooms = reblooms + 1 WHERE blooms.id = %s", @@ -146,6 +151,10 @@ def rebloom(bloom_id: int) -> None: ) +def add_rebloom(*, sender: User, id: int) -> None: + original_bloom = get_bloom(id) + + def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: if limit is not None: limit_clause = "LIMIT %(limit)s" diff --git a/backend/endpoints.py b/backend/endpoints.py index 11e0769..a3c0426 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -212,12 +212,29 @@ def user_blooms(profile_username): return jsonify(user_blooms) -def rebloom(bloom_id): +def update_rebloom_counter(bloom_id): try: id_int = int(bloom_id) except ValueError: return make_response((f"Invalid bloom id", 400)) - blooms.rebloom(id_int) + blooms.update_rebloom_counter(id_int) + return jsonify( + { + "success": True, + } + ) + + +@jwt_required() +def send_rebloom(): + user = get_current_user() + bloom_id = request.json["id"] + try: + id_int = int(bloom_id) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.add_rebloom(sender=user, id=id_int) + return jsonify( { "success": True, diff --git a/backend/main.py b/backend/main.py index 03f1dca..f5d10df 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,8 @@ send_bloom, suggested_follows, user_blooms, - rebloom, + update_rebloom_counter, + send_rebloom, ) from dotenv import load_dotenv @@ -61,7 +62,12 @@ def main(): app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) - app.add_url_rule("/rebloom/", methods=["POST"], view_func=rebloom) + app.add_url_rule( + "/rebloom_counter/", + methods=["POST"], + view_func=update_rebloom_counter, + ) + app.add_url_rule("/rebloom", methods=["POST"], view_func=send_rebloom) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index fde0a00..ac38cb9 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -12,6 +12,7 @@ CREATE TABLE blooms ( content TEXT NOT NULL, send_timestamp TIMESTAMP NOT NULL, reblooms INT NOT NULL DEFAULT 0 + original_bloom_id BIGINT REFERENCES blooms(id) ); CREATE TABLE follows ( diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 26b22e4..f3aef56 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,4 +1,4 @@ -import { apiService, state } from "../index.mjs"; +import { apiService } from "../index.mjs"; /** * Create a bloom component @@ -97,15 +97,10 @@ async function handleRebloom(event) { const button = event.target; const id = button.getAttribute("data-id"); if (!id) return; - addRebloomToFeed(id); - await apiService.rebloom(id); -} -async function addRebloomToFeed(id) { - const bloomContent = state.timelineBlooms.find( - (bloom) => bloom.id == id - ).content; - apiService.postBloom(bloomContent); + // maybe rename to rebloom counter + await apiService.updateRebloomCounter(id); + // await apiService.postRebloom(id); } export { createBloom, handleRebloom }; diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index a2c4eab..66b29c0 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,9 +212,9 @@ async function postBloom(content) { } } -async function rebloom(id) { +async function updateRebloomCounter(id) { try { - const data = await _apiRequest(`/rebloom/${id}`, { + const data = await _apiRequest(`/rebloom_counter/${id}`, { method: "POST", }); if (data.success) { @@ -224,6 +224,19 @@ async function rebloom(id) { } catch (error) {} } +async function postRebloom(originalId) { + try { + const data = await _apiRequest(`/rebloom`, { + method: "POST", + body: JSON.stringify({ id: originalId }), + }); + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + } catch (error) {} +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -304,7 +317,8 @@ const apiService = { getBlooms, postBloom, getBloomsByHashtag, - rebloom, + updateRebloomCounter, + postRebloom, // User methods getProfile, From f341e75303949f82dfab36a34845d925660ffc2e Mon Sep 17 00:00:00 2001 From: Droid-An Date: Fri, 17 Oct 2025 17:34:39 +0100 Subject: [PATCH 4/5] done rebloom feature --- backend/data/blooms.py | 40 +++++++++++++++++++++++++------- db/schema.sql | 2 +- front-end/components/bloom.mjs | 21 +++++++++++++---- front-end/index.html | 3 +++ front-end/lib/api.mjs | 8 +++++++ front-end/tests/rebloom.spec.mjs | 20 +++++++++++++++- 6 files changed, 79 insertions(+), 15 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 33d457a..04bd140 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -17,11 +17,14 @@ class Bloom: original_bloom_id: int -def add_bloom(*, sender: User, content: str) -> Bloom: +def add_bloom( + *, sender: User, content: str, original_bloom_id: Optional[int] = None +) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] now = datetime.datetime.now(tz=datetime.UTC) bloom_id = int(now.timestamp() * 1000000) + print(original_bloom_id) with db_cursor() as cur: cur.execute( "INSERT INTO blooms (id, sender_id, content, send_timestamp, reblooms, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(reblooms)s,%(original_bloom_id)s)", @@ -31,7 +34,7 @@ def add_bloom(*, sender: User, content: str) -> Bloom: content=content, timestamp=datetime.datetime.now(datetime.UTC), reblooms=0, - original_bloom_id=None, + original_bloom_id=original_bloom_id, ), ) for hashtag in hashtags: @@ -72,7 +75,14 @@ def get_blooms_for_user( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp, reblooms = row + ( + bloom_id, + sender_username, + content, + timestamp, + reblooms, + original_bloom_id, + ) = row blooms.append( Bloom( id=bloom_id, @@ -80,7 +90,7 @@ def get_blooms_for_user( content=content, sent_timestamp=timestamp, reblooms=reblooms, - original_bloom_id=None, + original_bloom_id=original_bloom_id, ) ) return blooms @@ -89,20 +99,20 @@ def get_blooms_for_user( def get_bloom(bloom_id: int) -> Optional[Bloom]: with db_cursor() as cur: cur.execute( - "SELECT blooms.id, users.username, content, send_timestamp, reblooms FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", + "SELECT blooms.id, users.username, content, send_timestamp, reblooms, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", (bloom_id,), ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp, reblooms = row + bloom_id, sender_username, content, timestamp, reblooms, original_bloom_id = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, reblooms=reblooms, - original_bloom_id=None, + original_bloom_id=original_bloom_id, ) @@ -129,7 +139,14 @@ def get_blooms_with_hashtag( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp, reblooms = row + ( + bloom_id, + sender_username, + content, + timestamp, + reblooms, + original_bloom_id, + ) = row blooms.append( Bloom( id=bloom_id, @@ -137,7 +154,7 @@ def get_blooms_with_hashtag( content=content, sent_timestamp=timestamp, reblooms=reblooms, - original_bloom_id=None, + original_bloom_id=original_bloom_id, ) ) return blooms @@ -153,6 +170,11 @@ def update_rebloom_counter(bloom_id: int) -> None: def add_rebloom(*, sender: User, id: int) -> None: original_bloom = get_bloom(id) + if not original_bloom: + return None + content = original_bloom.content + update_rebloom_counter(id) + add_bloom(sender=sender, content=content, original_bloom_id=id) def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: diff --git a/db/schema.sql b/db/schema.sql index ac38cb9..4b743fb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -11,7 +11,7 @@ CREATE TABLE blooms ( sender_id INT NOT NULL REFERENCES users(id), content TEXT NOT NULL, send_timestamp TIMESTAMP NOT NULL, - reblooms INT NOT NULL DEFAULT 0 + reblooms INT NOT NULL DEFAULT 0, original_bloom_id BIGINT REFERENCES blooms(id) ); diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index f3aef56..7bd6f26 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -26,6 +26,7 @@ const createBloom = (template, bloom) => { "[data-action='share-bloom']" ); const rebloomCountEl = bloomFrag.querySelector("[data-rebloom-count]"); + const rebloomInfoEl = bloomFrag.querySelector("[data-rebloom-info]"); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; @@ -36,9 +37,22 @@ const createBloom = (template, bloom) => { .body.childNodes ); // redo to "bloom.reblooms || 0" once reblooms implemented to object - rebloomCountEl.textContent = bloom.reblooms; + rebloomCountEl.textContent = `Rebloomed ${bloom.reblooms} times`; + rebloomCountEl.hidden = bloom.reblooms == 0; rebloomButtonEl.setAttribute("data-id", bloom.id || ""); rebloomButtonEl.addEventListener("click", handleRebloom); + rebloomInfoEl.hidden = bloom.original_bloom_id === null; + + if (bloom.original_bloom_id !== null) { + apiService + // I had to write another fetch, because getBloom update state, which is causing recursion if I use it here + .fetchBloomData(bloom.original_bloom_id) + .then((originalBloom) => { + const timeStamp = _formatTimestamp(originalBloom.sent_timestamp); + //I used inner html to render the arrow ↪ sign + rebloomInfoEl.innerHTML = `↪ Rebloom of ${originalBloom.sender}'s post, posted ${timeStamp} ago`; + }); + } return bloomFrag; }; @@ -98,9 +112,8 @@ async function handleRebloom(event) { const id = button.getAttribute("data-id"); if (!id) return; - // maybe rename to rebloom counter - await apiService.updateRebloomCounter(id); - // await apiService.postRebloom(id); + // await apiService.updateRebloomCounter(id); + await apiService.postRebloom(id); } export { createBloom, handleRebloom }; diff --git a/front-end/index.html b/front-end/index.html index bf7de9b..e677686 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -175,6 +175,9 @@

Share a Bloom

Username +
+

+

rebloomed 0 times

diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index 66b29c0..06d4668 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -149,6 +149,13 @@ async function getBloom(bloomId) { return bloom; } +//this function doesn't update state as getBloom does +async function fetchBloomData(bloomId) { + const endpoint = `/bloom/${bloomId}`; + const bloom = await _apiRequest(endpoint); + return bloom; +} + async function getBlooms(username) { const endpoint = username ? `/blooms/${username}` : "/home"; @@ -314,6 +321,7 @@ const apiService = { // Bloom methods getBloom, + fetchBloomData, getBlooms, postBloom, getBloomsByHashtag, diff --git a/front-end/tests/rebloom.spec.mjs b/front-end/tests/rebloom.spec.mjs index 7e4c06a..89c9b5b 100644 --- a/front-end/tests/rebloom.spec.mjs +++ b/front-end/tests/rebloom.spec.mjs @@ -60,6 +60,24 @@ test.describe("Rebloom functionality", () => { await page.waitForTimeout(200); const rebloomCount = page.locator("[data-rebloom-count]").nth(1); await page.waitForTimeout(200); - await expect(rebloomCount).toHaveText("1"); + await expect(rebloomCount).toHaveText("Rebloomed 1 times"); + }); + + test("New rebloom is marked as a rebloom correctly", async ({ page }) => { + await loginAsSample(page); + + await postBloom(page, "My 666 bloom!"); + + await logout(page); + + // When I am logged in as Swiz who already follows Sample + await loginAsSwiz(page); + + await page.click(`[data-action="share-bloom"]`); + const rebloomInfo = page.locator("[data-rebloom-info]").first(); + await expect(rebloomInfo).toBeVisible(); + await expect(rebloomInfo).toHaveText( + "↪ Rebloom of sample's post, posted 1h ago" + ); }); }); From eaab801089fe02dc12a53fd7f72040dead504771 Mon Sep 17 00:00:00 2001 From: Droid-An Date: Fri, 17 Oct 2025 18:09:50 +0100 Subject: [PATCH 5/5] removed rebloom counter endpoint --- backend/main.py | 5 ----- front-end/components/bloom.mjs | 6 ++++-- front-end/index.html | 2 +- front-end/lib/api.mjs | 13 ------------- front-end/tests/rebloom.spec.mjs | 4 +--- front-end/tests/test-utils.mjs | 2 +- 6 files changed, 7 insertions(+), 25 deletions(-) diff --git a/backend/main.py b/backend/main.py index f5d10df..d3609cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -62,11 +62,6 @@ def main(): app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) - app.add_url_rule( - "/rebloom_counter/", - methods=["POST"], - view_func=update_rebloom_counter, - ) app.add_url_rule("/rebloom", methods=["POST"], view_func=send_rebloom) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 7bd6f26..9f76dc6 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -9,7 +9,9 @@ import { apiService } from "../index.mjs"; * {"id": Number, * "sender": username, * "content": "string from textarea", - * "sent_timestamp": "datetime as ISO 8601 formatted string"} + * "sent_timestamp": "datetime as ISO 8601 formatted string"}, + * "reblooms": "reblooms count", + * "original_bloom_id": "id of the rebloomed post" */ const createBloom = (template, bloom) => { @@ -50,7 +52,7 @@ const createBloom = (template, bloom) => { .then((originalBloom) => { const timeStamp = _formatTimestamp(originalBloom.sent_timestamp); //I used inner html to render the arrow ↪ sign - rebloomInfoEl.innerHTML = `↪ Rebloom of ${originalBloom.sender}'s post, posted ${timeStamp} ago`; + rebloomInfoEl.innerHTML = `↪ Rebloom of the ${originalBloom.sender}'s post, posted ${timeStamp} ago`; }); } diff --git a/front-end/index.html b/front-end/index.html index e677686..bfae7df 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -180,7 +180,7 @@

Share a Bloom

-

rebloomed 0 times

+

diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index 06d4668..48eb9fe 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -219,18 +219,6 @@ async function postBloom(content) { } } -async function updateRebloomCounter(id) { - try { - const data = await _apiRequest(`/rebloom_counter/${id}`, { - method: "POST", - }); - if (data.success) { - await getBlooms(); - await getProfile(state.currentUser); - } - } catch (error) {} -} - async function postRebloom(originalId) { try { const data = await _apiRequest(`/rebloom`, { @@ -325,7 +313,6 @@ const apiService = { getBlooms, postBloom, getBloomsByHashtag, - updateRebloomCounter, postRebloom, // User methods diff --git a/front-end/tests/rebloom.spec.mjs b/front-end/tests/rebloom.spec.mjs index 89c9b5b..fc7d42e 100644 --- a/front-end/tests/rebloom.spec.mjs +++ b/front-end/tests/rebloom.spec.mjs @@ -4,8 +4,6 @@ import { loginAsSample, loginAsJustSomeGuy, loginAsSwiz, - waitForLocatorToHaveMatches, - signUp, logout, postBloom, } from "./test-utils.mjs"; @@ -77,7 +75,7 @@ test.describe("Rebloom functionality", () => { const rebloomInfo = page.locator("[data-rebloom-info]").first(); await expect(rebloomInfo).toBeVisible(); await expect(rebloomInfo).toHaveText( - "↪ Rebloom of sample's post, posted 1h ago" + "↪ Rebloom of the sample's post, posted 1h ago" ); }); }); diff --git a/front-end/tests/test-utils.mjs b/front-end/tests/test-utils.mjs index 6105b7f..e9edcd7 100644 --- a/front-end/tests/test-utils.mjs +++ b/front-end/tests/test-utils.mjs @@ -62,7 +62,7 @@ export async function signUp(page, username) { * @param {string} content - Bloom content */ export async function postBloom(page, content) { - // Added timeouts here because tests try to fill textarea before the whole page loaded + // Added timeouts here because tests try to fill textarea before the whole page is loaded await page.waitForTimeout(400); await page.fill('[data-form="bloom"] textarea[name="content"]', content); await page.waitForTimeout(200);