From ef6b0588e7bba94d1642391e270aa2f189dca544 Mon Sep 17 00:00:00 2001 From: Wilhelm Svensgaard Rolstad Date: Fri, 13 Nov 2026 20:03:43 +0100 Subject: [PATCH 1/5] feat: added redux state management and created a slice to hold the state of all selected parts. All actions components affecting this list have been refactored to support the state management instead of the old context --- frontend/package-lock.json | 113 ++++++++++++++++++ frontend/package.json | 2 + frontend/src/App.tsx | 4 + .../src/components/AddToBuildFeedback.tsx | 18 +-- .../components/DesktopComponentDetailPage.tsx | 6 +- .../components/buttons/AddToBuildButton.tsx | 24 ++-- .../src/components/pageSections/Header.tsx | 6 +- frontend/src/main.tsx | 3 - frontend/src/pages/homePage/homePage.tsx | 2 +- .../pcBuilderPage/components/ClearBuild.tsx | 13 +- .../ComponentSelectionComponent.tsx | 20 +++- .../pcBuilderPage/components/PublishBuild.tsx | 6 +- .../src/pages/pcBuilderPage/pcBuilderPage.tsx | 8 +- frontend/src/state/selectedPartsSlice.js | 35 ++++++ frontend/src/state/store.js | 37 ++++++ frontend/src/test/PcPartCard.test.tsx | 57 ++++++--- frontend/tsconfig.app.json | 1 + 17 files changed, 304 insertions(+), 51 deletions(-) create mode 100644 frontend/src/state/selectedPartsSlice.js create mode 100644 frontend/src/state/store.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 81b3bfe..41a3037 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,12 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.14.0", "@tailwindcss/vite": "^4.3.3", "lucide-react": "^1.64.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-redux": "^9.3.1", "react-router": "^8.5.1", "tailwind-merge": "^3.7.0", "tailwindcss": "^4.3.3" @@ -945,6 +947,36 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.14.0.tgz", + "integrity": "sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.2.9", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/route-pattern": { "version": "0.22.1", "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", @@ -1215,6 +1247,18 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -1924,6 +1968,12 @@ "@types/node": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", @@ -3193,6 +3243,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.2.10", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.2.10.tgz", + "integrity": "sha512-pzANBj+0A9u+prdhBJ14DVi44vCvVUQzBa8DKt6I0AMCokPHtt722BK9IX5bFC70xUSU9TnzLRIJLTGqIzjEFw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4080,6 +4140,29 @@ "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.1.tgz", + "integrity": "sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.1.tgz", @@ -4124,6 +4207,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4134,6 +4232,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, "node_modules/rettime": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.12.tgz", @@ -4593,6 +4697,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index d54d3ad..405f269 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,10 +13,12 @@ "format:check": "prettier --check . --ignore-path ../.prettierignore" }, "dependencies": { + "@reduxjs/toolkit": "^2.14.0", "@tailwindcss/vite": "^4.3.3", "lucide-react": "^1.64.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-redux": "^9.3.1", "react-router": "^8.5.1", "tailwind-merge": "^3.7.0", "tailwindcss": "^4.3.3" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 15ce456..cb0e582 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,8 @@ import NavBar from "./components/pageSections/Header"; import Footer from "./components/pageSections/Footer"; import BackToTopButton from "./components/buttons/BackToTopButton"; import { Routes, Route, useParams } from "react-router"; +import { Provider } from 'react-redux'; +import { store } from './state/store'; function CategoryRoute() { const { category } = useParams<{ category: string }>(); @@ -15,6 +17,7 @@ function CategoryRoute() { function App() { return ( +
@@ -35,6 +38,7 @@ function App() {
+
); } diff --git a/frontend/src/components/AddToBuildFeedback.tsx b/frontend/src/components/AddToBuildFeedback.tsx index acfe6a8..81fe9b7 100644 --- a/frontend/src/components/AddToBuildFeedback.tsx +++ b/frontend/src/components/AddToBuildFeedback.tsx @@ -1,9 +1,12 @@ import type { PcPart } from "@/types/types"; -import { useBuild } from "@/context/BuildContext"; import { useEffect, useId, useRef } from "react"; -import { flushSync } from "react-dom"; import { ArrowRight, X } from "lucide-react"; import SuccessNotification from "./SuccessNotification"; +import { useNavigate } from "react-router"; +import { useSelector } from 'react-redux' +import {selectPartByCategory } from '@/state/selectedPartsSlice' + + type AddToBuildFeedbackProps = { part: PcPart; @@ -16,10 +19,12 @@ export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: const dialogRef = useRef(null); const titleId = useId(); const descriptionId = useId(); - const { parts } = useBuild(); - const savedPart = parts[part.category]; + const savedPart = useSelector((state) => + selectPartByCategory(state, part.category) + ); const secondaryButton = "cursor-pointer rounded-lg border border-secondary/50 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-surface-dark"; const primaryButton = "cursor-pointer rounded-lg border border-accent bg-accent px-5 py-2 text-xs font-semibold text-background transition-colors hover:bg-accent/90"; + const navigate = useNavigate(); useEffect(() => { dialogRef.current?.showModal(); @@ -30,9 +35,8 @@ export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: } function goToBuild() { - // Remove the feedback before the browser saves this page for Back navigation. - flushSync(() => onClose()); - window.location.href = "/project2/builder"; + onClose(); + navigate("/builder"); } if (stage === "success") { diff --git a/frontend/src/components/DesktopComponentDetailPage.tsx b/frontend/src/components/DesktopComponentDetailPage.tsx index 0385996..b29fc18 100644 --- a/frontend/src/components/DesktopComponentDetailPage.tsx +++ b/frontend/src/components/DesktopComponentDetailPage.tsx @@ -1,4 +1,3 @@ -import { useBuild } from "@/context/BuildContext"; import { Trash2, X } from "lucide-react"; import { useId, useState } from "react"; import { Link } from "react-router"; @@ -9,13 +8,14 @@ import { getSpecifications, } from "@/utils/partSpecifications"; import AddToBuildButton from "./buttons/AddToBuildButton"; +import { useSelector } from "react-redux"; +import {selectIsPartInBuild} from '@/state/selectedPartsSlice' export default function DesktopComponentDetailPage({ part }: { part: PcPart }) { const [expanded, setExpanded] = useState(false); const [failedImage, setFailedImage] = useState(""); const specificationsId = useId(); - const { parts } = useBuild(); - const inBuild = parts[part.category]?.id === part.id; + const inBuild = useSelector((state) => selectIsPartInBuild(state, part.category)) const specifications = getSpecifications(part); const category = categories.find((item) => item.value === part.category)?.label ?? diff --git a/frontend/src/components/buttons/AddToBuildButton.tsx b/frontend/src/components/buttons/AddToBuildButton.tsx index 8107348..ddfa89b 100644 --- a/frontend/src/components/buttons/AddToBuildButton.tsx +++ b/frontend/src/components/buttons/AddToBuildButton.tsx @@ -1,11 +1,12 @@ import type { ReactNode } from "react"; import { useRef, useState } from "react"; import type { PcPart } from "@/types/types"; -import { useBuild } from "@/context/BuildContext"; import { twMerge } from "tailwind-merge"; import Button from "./Button"; import AddToBuildFeedback from "../AddToBuildFeedback"; import { getPartSpecifications } from "@/api/parts"; +import { addPart, removePart, selectIsPartInBuild, selectIsCategoryEmpty } from "@/state/selectedPartsSlice" +import { useSelector, useDispatch } from "react-redux"; type AddToBuildButtonProps = { part: PcPart; @@ -18,11 +19,14 @@ export default function AddToBuildButton({ children, className, }: AddToBuildButtonProps) { - const { parts, addPart, removePart } = useBuild(); const [showFeedback, setShowFeedback] = useState(false); - const inBuild = parts[part.category]?.id === part.id; + const inBuild = useSelector((state) => selectIsPartInBuild(state, part)) const [stage, setStage] = useState<"confirm" | "loading" | "success">("confirm"); const adding = useRef(false); + const dispatch = useDispatch(); + const isCategoryEmpty = useSelector((state) => + selectIsCategoryEmpty(state, part.category) + ); async function handleAdd(){ //Reuse existing specifications, or fetch them before adding the part. if (adding.current) return; @@ -30,11 +34,11 @@ export default function AddToBuildButton({ const loadingTimer = window.setTimeout(() => setStage("loading"), 500); try { const hasSpecifications = part.details && Object.keys(part.details).length > 0; - const specifications = hasSpecifications + const specifications = hasSpecifications //if the parts doesnt include specifications, then fetch them ? part.details : await getPartSpecifications(part.id); - const completePart = {...part, details: specifications,} as PcPart; - addPart(completePart); + const completePart = {...part, details: specifications,} as PcPart; //the complete part. + dispatch(addPart(completePart)) //adding part using redux setStage("success"); } finally { window.clearTimeout(loadingTimer); @@ -45,17 +49,19 @@ export default function AddToBuildButton({ async function handleClick(){ if (adding.current) return; if (inBuild) { - removePart(part.category); + dispatch(removePart(part.category)); setShowFeedback(false); return; } setStage("confirm"); setShowFeedback(true); - if(!parts[part.category]){ + if(isCategoryEmpty){ await handleAdd() } } + + return ( <> )} + {removedPart && ( + setRemovedPart(null)} + /> + )} ); } diff --git a/frontend/src/pages/pcBuilderPage/components/PublishBuild.tsx b/frontend/src/pages/pcBuilderPage/components/PublishBuild.tsx index 56ae95e..6c631c5 100644 --- a/frontend/src/pages/pcBuilderPage/components/PublishBuild.tsx +++ b/frontend/src/pages/pcBuilderPage/components/PublishBuild.tsx @@ -1,11 +1,13 @@ import { Link } from "react-router"; import { useRef, useState } from "react"; -import { useBuild } from "@/context/BuildContext"; import { publishBuild } from "@/api/builds"; import Button from "@/components/buttons/Button"; +import { useSelector } from 'react-redux' +import type { store } from "@/state/store"; + export default function PublishBuild() { - const { parts } = useBuild(); + const parts = useSelector((state: ReturnType) => state.selectedParts.parts) const dialogRef = useRef(null); const pending = useRef(false); const [title, setTitle] = useState(""); diff --git a/frontend/src/pages/pcBuilderPage/pcBuilderPage.tsx b/frontend/src/pages/pcBuilderPage/pcBuilderPage.tsx index a5a984c..93b4673 100644 --- a/frontend/src/pages/pcBuilderPage/pcBuilderPage.tsx +++ b/frontend/src/pages/pcBuilderPage/pcBuilderPage.tsx @@ -1,6 +1,7 @@ import SelectionComponent from "./components/ComponentSelectionComponent"; -import { useBuild } from "@/context/BuildContext"; +import { useSelector } from "react-redux"; +import type { store } from '@/state/store'; import { categories } from "@/types/categories"; import BuildSummaryComponent from "./components/BuildSummaryComponent"; import ClearBuild from "./components/ClearBuild"; @@ -8,7 +9,10 @@ import CompatabilityComponent from "./components/CompatabilityComponent"; import PublishBuild from "./components/PublishBuild"; export default function PcBuilderCase() { - const { parts } = useBuild(); // Inside your component + const parts = useSelector( + (state: ReturnType) => + state.selectedParts.parts + ); const selectedCount = Object.values(parts).length; const totalSum = Object.values(parts).reduce( (sum, part) => sum + (part.price_eur ?? 0), diff --git a/frontend/src/state/selectedPartsSlice.js b/frontend/src/state/selectedPartsSlice.js new file mode 100644 index 0000000..efc8860 --- /dev/null +++ b/frontend/src/state/selectedPartsSlice.js @@ -0,0 +1,35 @@ +import { createSlice } from "@reduxjs/toolkit"; + +/** @type {{ parts: Partial> }} */ +const initialState = { + parts: {}, +}; + +const selectedPartsSlice = createSlice({ + name: 'selected parts', + initialState, + reducers: { + addPart: (state, action) => { + const part = action.payload + state.parts[part.category] = part + }, + removePart: (state, action) => { + delete state.parts[action.payload] + }, + resetBuild: (state) => { + state.parts = {} + } + } +}) + +export const selectIsPartInBuild = (state, part) => + state.selectedParts.parts[part.category]?.id === part.id; + +export const selectIsCategoryEmpty = (state, category) => + !state.selectedParts.parts[category]; + +export const selectPartByCategory = (state, category) => + state.selectedParts.parts[category]; + +export const { addPart, removePart, resetBuild } = selectedPartsSlice.actions; +export default selectedPartsSlice.reducer; \ No newline at end of file diff --git a/frontend/src/state/store.js b/frontend/src/state/store.js new file mode 100644 index 0000000..1c19616 --- /dev/null +++ b/frontend/src/state/store.js @@ -0,0 +1,37 @@ +import { configureStore } from '@reduxjs/toolkit'; +import selectedPartsReducer from './selectedPartsSlice'; + +function loadBuild() { + try { + const saved = localStorage.getItem('pc-builder'); + return saved ? JSON.parse(saved) ?? {} : {}; + } catch { + return {}; + } +} + +export const store = configureStore({ + reducer: { + selectedParts: selectedPartsReducer, + }, + preloadedState: { + selectedParts: { + parts: loadBuild(), + }, + }, +}); + +let previousParts = store.getState().selectedParts.parts; + +store.subscribe(() => { + const parts = store.getState().selectedParts.parts; + + if (parts === previousParts) return; + previousParts = parts; + + try { + localStorage.setItem('pc-builder', JSON.stringify(parts)); + } catch { + console.warn('Could not save the build in this browser.'); + } +}); \ No newline at end of file diff --git a/frontend/src/test/PcPartCard.test.tsx b/frontend/src/test/PcPartCard.test.tsx index c8e778d..17904b6 100644 --- a/frontend/src/test/PcPartCard.test.tsx +++ b/frontend/src/test/PcPartCard.test.tsx @@ -1,13 +1,15 @@ import { beforeEach, expect, it, vi } from "vitest"; import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { configureStore } from "@reduxjs/toolkit"; +import { Provider } from "react-redux"; +import type { ReactElement, ReactNode } from "react"; import PcPartCard from "@/components/PcPartCard"; import { getPartSpecifications } from "@/api/parts"; -import { useBuild } from "@/context/BuildContext"; +import selectedPartsReducer from "@/state/selectedPartsSlice"; import type { PcPart } from "@/types/types"; vi.mock("@/api/parts", () => ({ getPartSpecifications: vi.fn() })); -vi.mock("@/context/BuildContext", () => ({ useBuild: vi.fn() })); vi.mock("@/components/AddToBuildFeedback", () => ({ default: () => null })); const part = { @@ -20,23 +22,31 @@ const part = { details: {}, } as PcPart; const details = { memory: 12 } as PcPart["details"]; -const addPart = vi.fn(); + +function renderWithStore(ui: ReactElement) { + const store = configureStore({ + reducer: { selectedParts: selectedPartsReducer }, + }); + + return { + store, + ...render(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }), + }; +} beforeEach(() => { vi.resetAllMocks(); - vi.mocked(useBuild).mockReturnValue({ - parts: {}, - addPart, - removePart: vi.fn(), - resetBuild: vi.fn(), - }); }); it("retains specifications for reopening details and adding from the card", async () => { const user = userEvent.setup(); const onShowDetails = vi.fn(); vi.mocked(getPartSpecifications).mockResolvedValue(details); - const { rerender } = render( + const { rerender, store } = renderWithStore( , ); @@ -51,7 +61,10 @@ it("retains specifications for reopening details and adding from the card", asyn expect(onShowDetails).toHaveBeenLastCalledWith({ ...updatedPart, details }); await user.click(screen.getByRole("button", { name: "Add to build" })); - expect(addPart).toHaveBeenCalledWith({ ...updatedPart, details }); + expect(store.getState().selectedParts.parts.GPU).toEqual({ + ...updatedPart, + details, + }); expect(getPartSpecifications).toHaveBeenCalledTimes(1); }); @@ -61,7 +74,7 @@ it("shows an error and lets the user retry a failed details request", async () = vi.mocked(getPartSpecifications) .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce(details); - render(); + renderWithStore(); await user.click(screen.getByRole("button", { name: "Show details" })); expect(screen.getByRole("alert")).toHaveTextContent( @@ -82,7 +95,7 @@ it("disables repeat detail clicks and ignores a response after the card unmounts resolve = done; }), ); - const { unmount } = render( + const { unmount } = renderWithStore( , ); @@ -97,14 +110,26 @@ it("disables repeat detail clicks and ignores a response after the card unmounts expect(onShowDetails).not.toHaveBeenCalled(); }); -it("can still add directly without opening details first", async () => { +it("adds directly and removes the part through Redux without opening details", async () => { const user = userEvent.setup(); vi.mocked(getPartSpecifications).mockResolvedValue(details); - render(); + const { store } = renderWithStore( + , + ); await user.click(screen.getByRole("button", { name: "Add to build" })); await waitFor(() => - expect(addPart).toHaveBeenCalledWith({ ...part, details }), + expect(store.getState().selectedParts.parts.GPU).toEqual({ + ...part, + details, + }), ); expect(getPartSpecifications).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Remove from build" })); + expect(store.getState().selectedParts.parts).toEqual({}); + expect( + screen.getByRole("button", { name: "Add to build" }), + ).toBeInTheDocument(); + expect(getPartSpecifications).toHaveBeenCalledTimes(1); }); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 96b7d9e..0d80322 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -7,6 +7,7 @@ "types": ["vite/client"], "allowArbitraryExtensions": true, "skipLibCheck": true, + "allowJs": true, /* Bundler mode */ "moduleResolution": "bundler", From 0fd26491b0b5ebf4d5265155af5c60d077503bfc Mon Sep 17 00:00:00 2001 From: Wilhelm Svensgaard Rolstad Date: Fri, 13 Nov 2026 20:04:39 +0100 Subject: [PATCH 2/5] del: deleted the old context --- frontend/src/context/BuildContext.ts | 23 --------- frontend/src/context/BuildProvider.tsx | 64 -------------------------- 2 files changed, 87 deletions(-) delete mode 100644 frontend/src/context/BuildContext.ts delete mode 100644 frontend/src/context/BuildProvider.tsx diff --git a/frontend/src/context/BuildContext.ts b/frontend/src/context/BuildContext.ts deleted file mode 100644 index 4b14a57..0000000 --- a/frontend/src/context/BuildContext.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createContext, useContext } from "react"; -import type { PcPart } from "@/types/types"; - -export type BuildParts = Partial>; - -type BuildContextValue = { - parts: BuildParts; - addPart: (part: PcPart) => void; - removePart: (category: PcPart["category"]) => void; - resetBuild: () => void; -}; - -export const BuildContext = createContext(null); - -export function useBuild() { - const context = useContext(BuildContext); - - if (!context) { - throw new Error("useBuild must be used inside BuildProvider"); - } - - return context; -} diff --git a/frontend/src/context/BuildProvider.tsx b/frontend/src/context/BuildProvider.tsx deleted file mode 100644 index f6b6298..0000000 --- a/frontend/src/context/BuildProvider.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useState } from "react"; -import type { ReactNode } from "react"; -import type { PcPart } from "@/types/types"; -import { BuildContext } from "./BuildContext"; -import type { BuildParts } from "./BuildContext"; -import SuccessNotification from "@/components/SuccessNotification"; - -const STORAGE_KEY = "pc-builder"; - -export default function BuildProvider({ children }: { children: ReactNode }) { - const [removedPart, setRemovedPart] = useState(null); - const [parts, setParts] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - return saved ? (JSON.parse(saved) ?? {}) : {}; - } catch { - return {}; - } - }); - - useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(parts)); - } catch { - console.warn("Could not save the build in this browser."); - } - }, [parts]); - - function addPart(part: PcPart) { - setParts((previous) => ({ - ...previous, - [part.category]: part, - })); - } - - function removePart(category: PcPart["category"]) { - const part = parts[category]; - if (!part) return; - setRemovedPart(part); - setParts((previous) => { - const updated = { ...previous }; - delete updated[category]; - return updated; - }); - } - - function resetBuild() { - setParts({}); - } - - return ( - - {children} - {removedPart && ( - setRemovedPart(null)} - /> - )} - - ); -} From 75412dceef03c03e094d199d00f040db33301070 Mon Sep 17 00:00:00 2001 From: Wilhelm Svensgaard Rolstad Date: Fri, 13 Nov 2026 20:12:20 +0100 Subject: [PATCH 3/5] fix: small ui bug --- frontend/src/components/DesktopComponentDetailPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/DesktopComponentDetailPage.tsx b/frontend/src/components/DesktopComponentDetailPage.tsx index b29fc18..406bb38 100644 --- a/frontend/src/components/DesktopComponentDetailPage.tsx +++ b/frontend/src/components/DesktopComponentDetailPage.tsx @@ -15,12 +15,13 @@ export default function DesktopComponentDetailPage({ part }: { part: PcPart }) { const [expanded, setExpanded] = useState(false); const [failedImage, setFailedImage] = useState(""); const specificationsId = useId(); - const inBuild = useSelector((state) => selectIsPartInBuild(state, part.category)) + const inBuild = useSelector((state) => selectIsPartInBuild(state, part)) const specifications = getSpecifications(part); const category = categories.find((item) => item.value === part.category)?.label ?? part.category; const imageUrl = part.image_url?.trim() ?? ""; + return (
From 77dc5a4d99f9119581e8f9fa94b5e839f010dddf Mon Sep 17 00:00:00 2001 From: Wilhelm Svensgaard Rolstad Date: Sat, 14 Nov 2026 10:16:59 +0100 Subject: [PATCH 4/5] refactor: replace auth context with Redux and centralize notifications --- frontend/src/App.tsx | 9 +- frontend/src/api/auth.ts | 84 ++++++++ .../src/components/AddToBuildFeedback.tsx | 21 -- frontend/src/components/AuthButton.tsx | 20 +- ...ccessNotification.tsx => Notification.tsx} | 122 ++++++++--- .../components/buttons/AddToBuildButton.tsx | 60 ++++-- frontend/src/context/AuthContext.ts | 23 --- frontend/src/context/AuthProvider.tsx | 56 ----- frontend/src/main.tsx | 16 +- .../src/pages/accountPage/accountPage.tsx | 11 +- frontend/src/pages/loginPage/loginPage.tsx | 7 +- .../ComponentSelectionComponent.tsx | 16 +- frontend/src/state/BuildStateProvider.tsx | 46 ----- frontend/src/state/authSlice.js | 38 ++++ frontend/src/state/notificationSlice.js | 36 ++++ frontend/src/state/selectedPartsSlice.js | 10 + frontend/src/state/store.js | 10 +- frontend/src/state/useSavedBuild.ts | 14 +- frontend/src/test/BuildProvider.test.tsx | 25 ++- frontend/src/test/SavedBuildWorkflow.test.tsx | 75 +++++-- frontend/src/test/auth.test.tsx | 194 ++++++++++++++++++ 21 files changed, 638 insertions(+), 255 deletions(-) create mode 100644 frontend/src/api/auth.ts rename frontend/src/components/{SuccessNotification.tsx => Notification.tsx} (52%) delete mode 100644 frontend/src/context/AuthContext.ts delete mode 100644 frontend/src/context/AuthProvider.tsx delete mode 100644 frontend/src/state/BuildStateProvider.tsx create mode 100644 frontend/src/state/authSlice.js create mode 100644 frontend/src/state/notificationSlice.js create mode 100644 frontend/src/test/auth.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index abb729e..4b8c57a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,9 @@ import BackToTopButton from "./components/buttons/BackToTopButton"; import LoginPage from "./pages/loginPage/loginPage"; import AccountPage from "./pages/accountPage/accountPage"; import { Routes, Route, useLocation, useParams } from "react-router"; +import Notification from "./components/Notification"; +import { useSelector } from "react-redux"; +import type { RootState } from "./state/store"; function CategoryRoute() { const { category } = useParams<{ category: string }>(); @@ -21,13 +24,16 @@ function BuilderRoute() { } function App() { + const accountKey = useSelector( + (state: RootState) => state.auth.user?.uid ?? "guest", + ); return (
- + } /> } />
+
); } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..f45779b --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,84 @@ +import { + GoogleAuthProvider, + createUserWithEmailAndPassword, + onAuthStateChanged, + signInWithEmailAndPassword, + signInWithPopup, + signOut, +} from "firebase/auth"; +import { auth } from "@/lib/firebase"; +import { authFailed, authResolved } from "@/state/authSlice"; +import { restoreBuild } from "@/state/selectedPartsSlice"; +import { dismissNotification } from "@/state/notificationSlice"; +import { loadBuildDraft, saveBuildDraft } from "@/state/buildStorage"; +import type { createBuildStore } from "@/state/store"; + +// Called once at startup. Firebase remains responsible for restoring the session +// and issuing tokens; Redux contains only the profile used by the interface. +export function startAuthSync(store: ReturnType) { + let storageKey: string | null = null; + let previousDraft = store.getState().selectedParts; + + const stopSaving = store.subscribe(() => { + const draft = store.getState().selectedParts; + if (storageKey && draft !== previousDraft) { + previousDraft = draft; + saveBuildDraft(storageKey, draft); + } + }); + + const stopAuth = onAuthStateChanged( + auth, + (user) => { + const nextKey = `pc-builder-v2:${user?.uid ?? "guest"}`; + if (nextKey !== storageKey) { + // Pause persistence while replacing the previous account's draft. + storageKey = null; + store.dispatch(restoreBuild(loadBuildDraft(nextKey))); + store.dispatch(dismissNotification()); + previousDraft = store.getState().selectedParts; + storageKey = nextKey; + saveBuildDraft(storageKey, previousDraft); + } + store.dispatch( + authResolved( + user + ? { + uid: user.uid, + displayName: user.displayName, + email: user.email, + } + : null, + ), + ); + }, + () => { + store.dispatch( + authFailed( + "Could not check your sign-in status. Please refresh and try again.", + ), + ); + }, + ); + + return () => { + stopAuth(); + stopSaving(); + }; +} + +export async function login() { + await signInWithPopup(auth, new GoogleAuthProvider()); +} + +export async function loginWithEmail(email: string, password: string) { + await signInWithEmailAndPassword(auth, email.trim(), password); +} + +export async function registerWithEmail(email: string, password: string) { + await createUserWithEmailAndPassword(auth, email.trim(), password); +} + +export async function logout() { + await signOut(auth); +} diff --git a/frontend/src/components/AddToBuildFeedback.tsx b/frontend/src/components/AddToBuildFeedback.tsx index 81fe9b7..c2056bc 100644 --- a/frontend/src/components/AddToBuildFeedback.tsx +++ b/frontend/src/components/AddToBuildFeedback.tsx @@ -1,13 +1,9 @@ import type { PcPart } from "@/types/types"; import { useEffect, useId, useRef } from "react"; import { ArrowRight, X } from "lucide-react"; -import SuccessNotification from "./SuccessNotification"; -import { useNavigate } from "react-router"; import { useSelector } from 'react-redux' import {selectPartByCategory } from '@/state/selectedPartsSlice' - - type AddToBuildFeedbackProps = { part: PcPart; stage: "confirm" | "loading" | "success"; @@ -24,7 +20,6 @@ export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: ); const secondaryButton = "cursor-pointer rounded-lg border border-secondary/50 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-surface-dark"; const primaryButton = "cursor-pointer rounded-lg border border-accent bg-accent px-5 py-2 text-xs font-semibold text-background transition-colors hover:bg-accent/90"; - const navigate = useNavigate(); useEffect(() => { dialogRef.current?.showModal(); @@ -34,22 +29,6 @@ export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: dialogRef.current?.close(); } - function goToBuild() { - onClose(); - navigate("/builder"); - } - - if (stage === "success") { - return ( - - ); - } - return ( - Loading... - - ); - } + const user = useSelector((state: RootState) => state.auth.user); return ( -
); } diff --git a/frontend/src/state/BuildStateProvider.tsx b/frontend/src/state/BuildStateProvider.tsx deleted file mode 100644 index f97f59f..0000000 --- a/frontend/src/state/BuildStateProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useContext, useEffect, useState } from "react"; -import type { ReactNode } from "react"; -import { Provider } from "react-redux"; -import { AuthContext } from "@/context/AuthContext"; -import { createBuildStore } from "./store"; -import { loadBuildDraft, saveBuildDraft } from "./buildStorage"; - -export default function BuildStateProvider({ - children, -}: { - children: ReactNode; -}) { - const { user, loading } = useContext(AuthContext) ?? { - user: null, - loading: false, - }; - if (loading) return

Loading...

; - const storageKey = `pc-builder-v2:${user?.uid ?? "guest"}`; - return ( - - {children} - - ); -} - -function ScopedStore({ - children, - storageKey, -}: { - children: ReactNode; - storageKey: string; -}) { - const [store] = useState(() => createBuildStore(loadBuildDraft(storageKey))); - useEffect(() => { - let previous = store.getState().selectedParts; - saveBuildDraft(storageKey, previous); - return store.subscribe(() => { - const draft = store.getState().selectedParts; - if (draft !== previous) { - previous = draft; - saveBuildDraft(storageKey, draft); - } - }); - }, [store, storageKey]); - return {children}; -} diff --git a/frontend/src/state/authSlice.js b/frontend/src/state/authSlice.js new file mode 100644 index 0000000..b4a7333 --- /dev/null +++ b/frontend/src/state/authSlice.js @@ -0,0 +1,38 @@ +import { createSlice } from "@reduxjs/toolkit"; + +/** + * @typedef {{ uid: string, displayName: string | null, email: string | null }} AuthUser + */ + +/** @type {{ user: AuthUser | null, status: "loading" | "authenticated" | "unauthenticated", error: string | null }} */ +const initialState = { + user: null, + status: "loading", + error: null, +}; + +const authSlice = createSlice({ + name: "auth", + initialState, + reducers: { + authStarted: (state) => { + state.status = "loading"; + state.error = null; + }, + + authResolved: (state, action) => { + state.user = action.payload; + state.status = action.payload ? "authenticated" : "unauthenticated"; + state.error = null; + }, + + authFailed: (state, action) => { + state.error = action.payload; + state.status = state.user ? "authenticated" : "unauthenticated"; + }, + }, +}); + +export const { authStarted, authResolved, authFailed } = authSlice.actions; + +export default authSlice.reducer; diff --git a/frontend/src/state/notificationSlice.js b/frontend/src/state/notificationSlice.js new file mode 100644 index 0000000..ec62da9 --- /dev/null +++ b/frontend/src/state/notificationSlice.js @@ -0,0 +1,36 @@ +import { createSlice, nanoid } from "@reduxjs/toolkit"; + +/** + * @typedef {{ title: string, description: string, button?: { label: string, to: string } }} NotificationPayload + */ + +/** @type {{ current: (NotificationPayload & { id: string }) | null }} */ +const initialState = { current: null }; + +const notificationSlice = createSlice({ + name: "notification", + initialState, + reducers: { + showNotification: { + /** @param {NotificationPayload} notification */ + prepare: (notification) => ({ + payload: { ...notification, id: nanoid() }, + }), + /** + * @param {typeof initialState} state + * @param {import('@reduxjs/toolkit').PayloadAction} action + */ + reducer: (state, action) => { + state.current = action.payload; + }, + }, + dismissNotification: (state) => { + state.current = null; + }, + }, +}); + +export const { showNotification, dismissNotification } = + notificationSlice.actions; + +export default notificationSlice.reducer; diff --git a/frontend/src/state/selectedPartsSlice.js b/frontend/src/state/selectedPartsSlice.js index 1c29c6e..a875ed0 100644 --- a/frontend/src/state/selectedPartsSlice.js +++ b/frontend/src/state/selectedPartsSlice.js @@ -11,6 +11,15 @@ const selectedPartsSlice = createSlice({ name: "selected parts", initialState, reducers: { + /** + * @param {typeof initialState} state + * @param {import('@reduxjs/toolkit').PayloadAction} action + */ + restoreBuild: (state, action) => { + state.parts = action.payload.parts; + state.savedBuild = action.payload.savedBuild; + state.isDirty = action.payload.isDirty; + }, addPart: (state, action) => { const part = action.payload; state.parts[part.category] = part; @@ -58,6 +67,7 @@ export const selectPartByCategory = (state, category) => state.selectedParts.parts[category]; export const { + restoreBuild, addPart, removePart, resetBuild, diff --git a/frontend/src/state/store.js b/frontend/src/state/store.js index 3cb4b65..2b3bb6e 100644 --- a/frontend/src/state/store.js +++ b/frontend/src/state/store.js @@ -1,12 +1,20 @@ import { configureStore } from "@reduxjs/toolkit"; import selectedPartsReducer from "./selectedPartsSlice"; +import notificationReducer from "./notificationSlice"; +import authReducer from "./authSlice"; /** @param {import('./buildTypes').BuildDraft} [draft] */ export function createBuildStore(draft) { return configureStore({ - reducer: { selectedParts: selectedPartsReducer }, + reducer: { + selectedParts: selectedPartsReducer, + notification: notificationReducer, + auth: authReducer, + }, ...(draft ? { preloadedState: { selectedParts: draft } } : {}), }); } +export const store = createBuildStore(); + /** @typedef {ReturnType["getState"]>} RootState */ diff --git a/frontend/src/state/useSavedBuild.ts b/frontend/src/state/useSavedBuild.ts index b44eaf1..f467f17 100644 --- a/frontend/src/state/useSavedBuild.ts +++ b/frontend/src/state/useSavedBuild.ts @@ -1,6 +1,6 @@ import { bindActionCreators } from "@reduxjs/toolkit"; import { useMemo } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch, useSelector, useStore } from "react-redux"; import type { PcPart } from "@/types/types"; import type { SavedBuildInfo } from "./buildTypes"; import type { RootState } from "./store"; @@ -17,6 +17,8 @@ import { export function useSavedBuild() { const draft = useSelector((state: RootState) => state.selectedParts); const dispatch = useDispatch(); + const store = useStore(); + const accountId = useSelector((state: RootState) => state.auth.user?.uid); const actions = useMemo( () => bindActionCreators( @@ -29,9 +31,15 @@ export function useSavedBuild() { markSaved, forgetSavedBuild, }, - dispatch, + (action) => { + // Ignore a completed save/open request from an account we have left. + if (store.getState().auth.user?.uid === accountId) { + return dispatch(action); + } + return action; + }, ), - [dispatch], + [dispatch, store, accountId], ); return { ...draft, ...actions }; } diff --git a/frontend/src/test/BuildProvider.test.tsx b/frontend/src/test/BuildProvider.test.tsx index ef87378..4a2d057 100644 --- a/frontend/src/test/BuildProvider.test.tsx +++ b/frontend/src/test/BuildProvider.test.tsx @@ -1,14 +1,22 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; -import BuildStateProvider from "@/state/BuildStateProvider"; +import { Provider } from "react-redux"; +import { createBuildStore } from "@/state/store"; +import { startAuthSync } from "@/api/auth"; import { useSavedBuild as useBuild } from "@/state/useSavedBuild"; import type { PcPart } from "@/types/types"; const STORAGE_KEY = "pc-builder"; -// The removal notification uses browser popover APIs that jsdom does not have. -vi.mock("@/components/SuccessNotification", () => ({ default: () => null })); +vi.mock("@/lib/firebase", () => ({ auth: {} })); +vi.mock("firebase/auth", () => ({ + onAuthStateChanged: vi.fn((_auth, callback) => { + callback(null); + return vi.fn(); + }), +})); +const subscriptions: (() => void)[] = []; const part = (id: string, category: string, name: string) => ({ id, category, name, price_eur: 100, details: {} }) as unknown as PcPart; @@ -17,9 +25,11 @@ const otherCpu = part("cpu-2", "CPU", "Other CPU"); const gpu = part("gpu-1", "GPU", "Test GPU"); function renderBuild() { + const store = createBuildStore(); + subscriptions.push(startAuthSync(store)); return renderHook(() => useBuild(), { wrapper: ({ children }: { children: ReactNode }) => ( - {children} + {children} ), }); } @@ -27,7 +37,10 @@ function renderBuild() { const saved = () => JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null"); beforeEach(() => localStorage.clear()); -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + subscriptions.splice(0).forEach((stop) => stop()); + vi.restoreAllMocks(); +}); describe("loading a saved build", () => { it("starts with an empty build when nothing is saved", () => { @@ -178,7 +191,7 @@ describe("changing the build", () => { }); describe("useBuild", () => { - it("throws when used outside BuildStateProvider", () => { + it("throws when used outside the Redux Provider", () => { vi.spyOn(console, "error").mockImplementation(() => {}); expect(() => renderHook(() => useBuild())).toThrow( diff --git a/frontend/src/test/SavedBuildWorkflow.test.tsx b/frontend/src/test/SavedBuildWorkflow.test.tsx index 193df4a..dde3d61 100644 --- a/frontend/src/test/SavedBuildWorkflow.test.tsx +++ b/frontend/src/test/SavedBuildWorkflow.test.tsx @@ -4,10 +4,17 @@ import { screen, waitFor, cleanup, + act, + renderHook, } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BrowserRouter } from "react-router"; -import BuildStateProvider from "@/state/BuildStateProvider"; +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import type { User } from "firebase/auth"; +import { Provider } from "react-redux"; +import { createBuildStore } from "@/state/store"; +import { startAuthSync } from "@/api/auth"; import { useSavedBuild } from "@/state/useSavedBuild"; import type { PcPart } from "@/types/types"; import OpenSavedBuild from "@/pages/pcBuilderPage/components/OpenSavedBuild"; @@ -15,12 +22,25 @@ import OpenSavedBuild from "@/pages/pcBuilderPage/components/OpenSavedBuild"; const mocks = vi.hoisted(() => ({ auth: { user: { uid: "owner-a" }, loading: false }, getMyBuild: vi.fn(), + listener: null as ((user: User | null) => void) | null, })); -vi.mock("@/context/AuthContext", async () => { - const { createContext } = await import("react"); - return { AuthContext: createContext(mocks.auth) }; -}); -vi.mock("@/components/SuccessNotification", () => ({ default: () => null })); +vi.mock("@/lib/firebase", () => ({ auth: {} })); +vi.mock("firebase/auth", () => ({ + onAuthStateChanged: vi.fn((_auth, callback) => { + mocks.listener = callback; + callback(mocks.auth.user); + return () => { + mocks.listener = null; + }; + }), +})); + +function TestStore({ children }: { children: ReactNode }) { + const [store] = useState(() => createBuildStore()); + useEffect(() => startAuthSync(store), [store]); + return {children}; +} + vi.mock("@/api/builds", () => ({ getMyBuild: mocks.getMyBuild })); const cpu = { @@ -66,11 +86,25 @@ afterEach(() => { }); describe("saved build workflow", () => { + it("ignores a save completion from a previous account", () => { + const store = createBuildStore(); + const stop = startAuthSync(store); + const { result, unmount } = renderHook(() => useSavedBuild(), { + wrapper: ({ children }) => {children}, + }); + const markPreviousAccountSaved = result.current.markSaved; + act(() => mocks.listener?.({ uid: "owner-b" } as User)); + act(() => markPreviousAccountSaved(info)); + expect(store.getState().selectedParts.savedBuild).toBeNull(); + unmount(); + stop(); + }); + it("restores the loaded build ID and selections after remounting and tracks edits", async () => { const view = render( - + - , + , ); fireEvent.click(screen.getByText("Load fixture")); await waitFor(() => @@ -80,9 +114,9 @@ describe("saved build workflow", () => { ); view.unmount(); render( - + - , + , ); expect(state().savedBuild.id).toBe("12"); expect(state().parts.CPU.id).toBe("cpu-1"); @@ -96,9 +130,9 @@ describe("saved build workflow", () => { it("keeps local selections as a new draft when the active saved build is deleted", () => { render( - + - , + , ); fireEvent.click(screen.getByText("Load fixture")); fireEvent.click(screen.getByText("Forget deleted build")); @@ -109,16 +143,17 @@ describe("saved build workflow", () => { it("does not carry a saved build into another account", () => { const view = render( - + - , + , ); fireEvent.click(screen.getByText("Load fixture")); mocks.auth.user = { uid: "owner-b" }; + act(() => mocks.listener?.(mocks.auth.user as User)); view.rerender( - + - , + , ); expect(state().savedBuild).toBeNull(); expect(state().parts).toEqual({}); @@ -134,13 +169,13 @@ describe("saved build workflow", () => { parts: [cpu], }); render( - + - , + , ); await waitFor(() => expect(screen.getByTestId("state")).toBeInTheDocument(), @@ -168,13 +203,13 @@ describe("saved build workflow", () => { parts: [cpu], }); render( - + - , + , ); await waitFor(() => expect(screen.getByTestId("state")).toBeInTheDocument(), diff --git a/frontend/src/test/auth.test.tsx b/frontend/src/test/auth.test.tsx new file mode 100644 index 0000000..fb2a64c --- /dev/null +++ b/frontend/src/test/auth.test.tsx @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { Provider } from "react-redux"; +import { MemoryRouter } from "react-router"; +import type { User } from "firebase/auth"; +import { startAuthSync } from "@/api/auth"; +import { createBuildStore } from "@/state/store"; +import { addPart } from "@/state/selectedPartsSlice"; +import AuthButton from "@/components/AuthButton"; +import LoginPage from "@/pages/loginPage/loginPage"; + +const mocks = vi.hoisted(() => ({ + changed: null as ((user: User | null) => void) | null, + failed: null as (() => void) | null, + unsubscribe: vi.fn(), + emailLogin: vi.fn(), + register: vi.fn(), + googleLogin: vi.fn(), + signOut: vi.fn(), +})); +vi.mock("@/lib/firebase", () => ({ auth: {} })); +vi.mock("firebase/auth", () => ({ + onAuthStateChanged: vi.fn((_auth, changed, failed) => { + mocks.changed = changed; + mocks.failed = failed; + return mocks.unsubscribe; + }), + GoogleAuthProvider: class {}, + signInWithEmailAndPassword: mocks.emailLogin, + createUserWithEmailAndPassword: mocks.register, + signInWithPopup: mocks.googleLogin, + signOut: mocks.signOut, +})); + +const profile = { + uid: "owner-a", + displayName: "Test user", + email: "test@example.com", +}; +const subscriptions: (() => void)[] = []; +function setup() { + const store = createBuildStore(); + const stop = startAuthSync(store); + subscriptions.push(stop); + return { store, stop }; +} +function renderLogin(store: ReturnType) { + return render( + + + + + + , + ); +} + +beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + mocks.emailLogin.mockReset(); + mocks.register.mockReset(); + mocks.googleLogin.mockReset(); +}); +afterEach(() => { + cleanup(); + subscriptions.splice(0).forEach((stop) => stop()); +}); + +it("restores a Firebase session into Redux as plain profile data and handles logout", () => { + const { store } = setup(); + expect(store.getState().auth.status).toBe("loading"); + mocks.changed?.({ ...profile, getIdToken: vi.fn() } as unknown as User); + expect(store.getState().auth).toEqual({ + user: profile, + status: "authenticated", + error: null, + }); + mocks.changed?.(null); + expect(store.getState().auth).toEqual({ + user: null, + status: "unauthenticated", + error: null, + }); +}); + +it("keeps the previous account's draft isolated and restores it on return", () => { + const { store } = setup(); + mocks.changed?.(profile as User); + const cpu = { + id: "cpu-1", + category: "CPU", + name: "CPU", + price_eur: 100, + details: {}, + }; + store.dispatch(addPart(cpu)); + mocks.changed?.({ ...profile, uid: "owner-b" } as User); + expect(store.getState().selectedParts.parts).toEqual({}); + expect( + JSON.parse(localStorage.getItem("pc-builder-v2:owner-a")!).parts, + ).toEqual({ CPU: cpu }); + mocks.changed?.(profile as User); + expect(store.getState().selectedParts.parts).toEqual({ CPU: cpu }); +}); + +it("does not overwrite an edited draft when Firebase reports the same user again", () => { + const { store } = setup(); + mocks.changed?.(profile as User); + store.dispatch(addPart({ id: "cpu-1", category: "CPU" })); + const draft = store.getState().selectedParts; + mocks.changed?.({ ...profile, displayName: "Updated name" } as User); + expect(store.getState().selectedParts).toBe(draft); + expect(store.getState().auth.user?.displayName).toBe("Updated name"); +}); + +it("ends loading if the authentication observer reports an error", () => { + const { store } = setup(); + mocks.failed?.(); + expect(store.getState().auth.status).toBe("unauthenticated"); + expect(store.getState().auth.error).toContain("Could not check"); +}); + +it("keeps Redux signed out when email login fails, without reading a null user", async () => { + const { store } = setup(); + mocks.changed?.(null); + mocks.emailLogin.mockRejectedValue(new Error("Wrong password")); + renderLogin(store); + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "test@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "incorrect" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not sign in", + ); + expect(store.getState().auth.user).toBeNull(); + expect(store.getState().auth.status).toBe("unauthenticated"); +}); + +it.each(["email", "registration", "Google"])( + "updates the account link after %s sign-in", + async (method) => { + const { store } = setup(); + mocks.changed?.(null); + const succeed = async () => { + mocks.changed?.(profile as User); + }; + mocks.emailLogin.mockImplementation(succeed); + mocks.register.mockImplementation(succeed); + mocks.googleLogin.mockImplementation(succeed); + renderLogin(store); + if (method === "Google") { + await act(async () => + fireEvent.click( + screen.getByRole("button", { name: "Continue with Google" }), + ), + ); + expect(mocks.googleLogin).toHaveBeenCalledOnce(); + } else { + if (method === "registration") + fireEvent.click(screen.getByRole("button", { name: "Register" })); + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "test@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Password"), { + target: { value: "password123" }, + }); + await act(async () => + fireEvent.click( + screen.getByRole("button", { + name: method === "registration" ? "Create account" : "Sign in", + }), + ), + ); + expect( + method === "registration" ? mocks.register : mocks.emailLogin, + ).toHaveBeenCalledOnce(); + } + expect(screen.getByRole("link", { name: "My account" })).toHaveAttribute( + "href", + "/account", + ); + expect(store.getState().auth.user).toEqual(profile); + }, +); From a11a8fb50540e90c54e0542329644aafe9f41fdd Mon Sep 17 00:00:00 2001 From: Wilhelm Svensgaard Rolstad Date: Sat, 14 Nov 2026 10:18:55 +0100 Subject: [PATCH 5/5] fix: linting --- frontend/src/api/graphql.ts | 2 +- frontend/src/api/parts.ts | 26 ++++----- .../src/components/AddToBuildFeedback.tsx | 58 ++++++++++++++----- .../components/DesktopComponentDetailPage.tsx | 5 +- .../components/MobileComponentDetailPage.tsx | 9 ++- frontend/src/components/Notification.tsx | 45 +++++++------- frontend/src/components/PcPartCardLayout.tsx | 2 +- frontend/src/components/buttons/Button.tsx | 6 +- frontend/src/lib/firebase.ts | 4 +- frontend/src/pages/homePage/homePage.tsx | 2 +- .../ComponentSelectionComponent.tsx | 10 ++-- frontend/src/test/checkCompatability.test.ts | 34 +++++++---- 12 files changed, 124 insertions(+), 79 deletions(-) diff --git a/frontend/src/api/graphql.ts b/frontend/src/api/graphql.ts index e699333..ecdd5d1 100644 --- a/frontend/src/api/graphql.ts +++ b/frontend/src/api/graphql.ts @@ -30,4 +30,4 @@ export async function graphql( } return result.data; -} \ No newline at end of file +} diff --git a/frontend/src/api/parts.ts b/frontend/src/api/parts.ts index 4a1a0c4..5738522 100644 --- a/frontend/src/api/parts.ts +++ b/frontend/src/api/parts.ts @@ -140,10 +140,10 @@ export async function getParts( export async function getPartSpecifications( id: string, signal?: AbortSignal, -): Promise { - const response = await fetch('/api/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, +): Promise { + const response = await fetch("/api/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, signal, body: JSON.stringify({ query: ` @@ -155,26 +155,26 @@ export async function getPartSpecifications( `, variables: { id }, }), - }) + }); if (!response.ok) { - throw new Error(`Could not load specifications (HTTP ${response.status}).`) + throw new Error(`Could not load specifications (HTTP ${response.status}).`); } const result: { - data?: { part: { specs: PcPart['details'] } | null } - errors?: { message: string }[] - } = await response.json() + data?: { part: { specs: PcPart["details"] } | null }; + errors?: { message: string }[]; + } = await response.json(); if (result.errors?.length) { - throw new Error(result.errors.map((error) => error.message).join(', ')) + throw new Error(result.errors.map((error) => error.message).join(", ")); } if (!result.data) { - throw new Error('The server returned no data.') + throw new Error("The server returned no data."); } if (!result.data.part) { - throw new Error('Part not found.') + throw new Error("Part not found."); } - return result.data.part.specs + return result.data.part.specs; } diff --git a/frontend/src/components/AddToBuildFeedback.tsx b/frontend/src/components/AddToBuildFeedback.tsx index c2056bc..058ef52 100644 --- a/frontend/src/components/AddToBuildFeedback.tsx +++ b/frontend/src/components/AddToBuildFeedback.tsx @@ -1,8 +1,8 @@ import type { PcPart } from "@/types/types"; import { useEffect, useId, useRef } from "react"; import { ArrowRight, X } from "lucide-react"; -import { useSelector } from 'react-redux' -import {selectPartByCategory } from '@/state/selectedPartsSlice' +import { useSelector } from "react-redux"; +import { selectPartByCategory } from "@/state/selectedPartsSlice"; type AddToBuildFeedbackProps = { part: PcPart; @@ -11,15 +11,22 @@ type AddToBuildFeedbackProps = { onClose: () => void; }; -export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: AddToBuildFeedbackProps) { +export default function AddToBuildFeedback({ + part, + stage, + onReplace, + onClose, +}: AddToBuildFeedbackProps) { const dialogRef = useRef(null); const titleId = useId(); const descriptionId = useId(); const savedPart = useSelector((state) => - selectPartByCategory(state, part.category) + selectPartByCategory(state, part.category), ); - const secondaryButton = "cursor-pointer rounded-lg border border-secondary/50 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-surface-dark"; - const primaryButton = "cursor-pointer rounded-lg border border-accent bg-accent px-5 py-2 text-xs font-semibold text-background transition-colors hover:bg-accent/90"; + const secondaryButton = + "cursor-pointer rounded-lg border border-secondary/50 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-surface-dark"; + const primaryButton = + "cursor-pointer rounded-lg border border-accent bg-accent px-5 py-2 text-xs font-semibold text-background transition-colors hover:bg-accent/90"; useEffect(() => { dialogRef.current?.showModal(); @@ -48,11 +55,18 @@ export default function AddToBuildFeedback({ part, stage, onReplace, onClose }:
-
-

+
+

{stage === "confirm" ? "Replace component?" : "Adding component…"}

-

+

{stage === "confirm" ? `You have already selected ${savedPart?.name}. Would you like to replace it with ${part.name}?` : `Adding ${part.name} to your build.`} @@ -74,10 +88,19 @@ export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: )}

Current component

-

{savedPart?.name}

+

+ {savedPart?.name} +

-

, + portalTarget, ); } diff --git a/frontend/src/components/PcPartCardLayout.tsx b/frontend/src/components/PcPartCardLayout.tsx index 2abc8f9..70fd031 100644 --- a/frontend/src/components/PcPartCardLayout.tsx +++ b/frontend/src/components/PcPartCardLayout.tsx @@ -11,7 +11,7 @@ type PcPartCardLayoutProps = { error?: ReactNode; skeleton?: boolean; }; -//Layot for the PcPartCard and the PcPartCardSkeleton. +//Layot for the PcPartCard and the PcPartCardSkeleton. export default function PcPartCardLayout({ image, name, diff --git a/frontend/src/components/buttons/Button.tsx b/frontend/src/components/buttons/Button.tsx index fc7983d..fb27531 100644 --- a/frontend/src/components/buttons/Button.tsx +++ b/frontend/src/components/buttons/Button.tsx @@ -30,7 +30,11 @@ export default function Button({ }} className={twMerge( "w-full rounded-lg bg-accent px-2 py-1.5 text-sm text-black", - disabled ? "cursor-not-allowed" : inactive ? "cursor-default" : "cursor-pointer", + disabled + ? "cursor-not-allowed" + : inactive + ? "cursor-default" + : "cursor-pointer", className, )} > diff --git a/frontend/src/lib/firebase.ts b/frontend/src/lib/firebase.ts index ce206ff..4bf1b9e 100644 --- a/frontend/src/lib/firebase.ts +++ b/frontend/src/lib/firebase.ts @@ -5,7 +5,7 @@ const app = initializeApp({ apiKey: "AIzaSyBa12YFnOadd1lkieNJAfMfebAnxRG57FI", authDomain: "full-circuit.firebaseapp.com", projectId: "full-circuit", - appId: "1:10539610170:web:d1241a25a1b0cd9e1ac503" + appId: "1:10539610170:web:d1241a25a1b0cd9e1ac503", }); -export const auth = getAuth(app); \ No newline at end of file +export const auth = getAuth(app); diff --git a/frontend/src/pages/homePage/homePage.tsx b/frontend/src/pages/homePage/homePage.tsx index cb45cae..5d9b6f8 100644 --- a/frontend/src/pages/homePage/homePage.tsx +++ b/frontend/src/pages/homePage/homePage.tsx @@ -7,7 +7,7 @@ import CategorySelection from "../../components/CategorySelection"; export default function HomePage() { const { search, setSearch, filtOptions, setFiltOptions } = useCatalogParams(); - + return (
diff --git a/frontend/src/pages/pcBuilderPage/components/ComponentSelectionComponent.tsx b/frontend/src/pages/pcBuilderPage/components/ComponentSelectionComponent.tsx index 74c654c..c6631fe 100644 --- a/frontend/src/pages/pcBuilderPage/components/ComponentSelectionComponent.tsx +++ b/frontend/src/pages/pcBuilderPage/components/ComponentSelectionComponent.tsx @@ -107,10 +107,12 @@ export default function ComponentSelectionComponent({ className="flex h-11 w-11 items-center justify-center bg-transparent" onClick={() => { dispatch(removePart(categoryValue)); - dispatch(showNotification({ - title: "Component removed", - description: `${part.name} was removed from your build.`, - })) + dispatch( + showNotification({ + title: "Component removed", + description: `${part.name} was removed from your build.`, + }), + ); }} >