From aa88c5e7a42b542e1c2c8a2d4433cc5126e5d0e9 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 19:53:58 +0100 Subject: [PATCH 01/17] chore: setup Apollo client --- frontend/package-lock.json | 16 ++++++++-------- frontend/package.json | 4 ++-- frontend/src/lib/apolloClient.ts | 31 +++++++++++++++++++++++++++++++ frontend/src/main.tsx | 12 +++++++++--- 4 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 frontend/src/lib/apolloClient.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e7be7c5..e16dfc0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@apollo/client": "^4.3.3", + "@apollo/client": "^4.3.8", "@tailwindcss/vite": "^4.3.3", - "graphql": "^16.14.2", + "graphql": "^16.15.0", "lucide-react": "^1.57.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -53,9 +53,9 @@ "license": "MIT" }, "node_modules/@apollo/client": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.3.tgz", - "integrity": "sha512-wMfOUaFKLgNsu0OMCg50gfhEHTV9/GyO7DxQvut7iSBUoePy0kvll3oWeY8KVPRfnVhYEzcwPJ/Jwz4uSe3fQA==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.8.tgz", + "integrity": "sha512-g04oCNYjbbjpecZmcUOE0fZLt3vMFwoZJ+o4iu0N3bDDhMR95/ZVzWL9v3mM7WDSPxI0++P2348yAbSWceLoZA==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", @@ -3243,9 +3243,9 @@ "license": "ISC" }, "node_modules/graphql": { - "version": "16.14.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", - "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.15.0.tgz", + "integrity": "sha512-32kz9FecohDiC/tgcViwnRXM7HFBVTPiW7pHO+DikKOI5KjhU/jNULOReXsrFqt2WAYbE9xFXsTffJw6iTO8qw==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" diff --git a/frontend/package.json b/frontend/package.json index 22d6202..e01915f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,9 +11,9 @@ "test": "vitest run" }, "dependencies": { - "@apollo/client": "^4.3.3", + "@apollo/client": "^4.3.8", "@tailwindcss/vite": "^4.3.3", - "graphql": "^16.14.2", + "graphql": "^16.15.0", "lucide-react": "^1.57.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/frontend/src/lib/apolloClient.ts b/frontend/src/lib/apolloClient.ts new file mode 100644 index 0000000..b749101 --- /dev/null +++ b/frontend/src/lib/apolloClient.ts @@ -0,0 +1,31 @@ +import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import { useAuthStore } from "../store/authStore"; + +const httpLink = new HttpLink({ + uri: `${import.meta.env.BASE_URL}graphql`, + + fetch: (uri, options) => { + const token = useAuthStore.getState().token; + + const headers = new Headers(options?.headers); + + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + return fetch(uri, { + ...options, + headers, + }); + }, +}); + +export const apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + watchQuery: { + fetchPolicy: "cache-first", + }, + }, +}); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9df58f5..74e50af 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,19 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; + +import { ApolloProvider } from "@apollo/client/react"; +import { apolloClient } from "./lib/apolloClient"; + import "./index.css"; import App from "./App.tsx"; createRoot(document.getElementById("root")!).render( - - - + + + + + , ); From 181e9be03656e8734abacd089c9184a81d6a2639 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 20:18:21 +0100 Subject: [PATCH 02/17] refactor: migrate useMyGalleries to Apollo Client with caching --- frontend/src/hooks/gallery/useMyGallery.ts | 134 ++++++++------------- 1 file changed, 51 insertions(+), 83 deletions(-) diff --git a/frontend/src/hooks/gallery/useMyGallery.ts b/frontend/src/hooks/gallery/useMyGallery.ts index 1e27ec3..880594b 100644 --- a/frontend/src/hooks/gallery/useMyGallery.ts +++ b/frontend/src/hooks/gallery/useMyGallery.ts @@ -1,94 +1,62 @@ -import { useEffect, useState } from "react"; -import type { Gallery } from "../../types/Gallery"; +import { gql } from "@apollo/client"; + import { useAuthStore } from "../../store/authStore"; +import { useQuery } from "@apollo/client/react"; +import type { Gallery } from "../../types/Gallery"; -type MyGalleriesResponse = { - data?: { - myGalleries: Gallery[]; - }; - errors?: { message: string }[]; +// Defines the expected shape of the GraphQL response +type MyGalleriesData = { + myGalleries: Gallery[]; }; -// Fetches the logged-in user's galleries -export function useMyGalleries() { - const token = useAuthStore((state) => state.token); - - const [galleries, setGalleries] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(""); - const [refreshKey, setRefreshKey] = useState(0); - - useEffect(() => { - const controller = new AbortController(); - - async function fetchGalleries() { - setIsLoading(true); - setError(""); - - try { - if (!token) { - throw new Error("You must be logged in"); - } - - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - signal: controller.signal, - body: JSON.stringify({ - query: ` - query { - myGalleries { - id - userId - title - artworkIds - } - } - `, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const result = (await response.json()) as MyGalleriesResponse; - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - if (!result.data) { - throw new Error("Could not load galleries"); - } - - if (!controller.signal.aborted) { - setGalleries(result.data.myGalleries); - } - } catch (err) { - if (controller.signal.aborted) return; - - setError( - err instanceof Error ? err.message : "Could not load galleries", - ); - } finally { - if (!controller.signal.aborted) { - setIsLoading(false); - } - } +// GraphQL query +const GET_MY_GALLERIES = gql` + query GetMyGalleries { + myGalleries { + id + userId + title + artworkIds } + } +`; - void fetchGalleries(); - - return () => controller.abort(); - }, [token, refreshKey]); +// This hook was implemented with assistance from ChatGPT +// Fetches and caches the logged-in user's galleries +export function useMyGalleries() { + // Get token + const token = useAuthStore((state) => state.token); + // Skip unauthenticated requests and reuse cached results when available + const { data, loading, error, refetch } = useQuery( + GET_MY_GALLERIES, + { + skip: !token, + fetchPolicy: "cache-first", + }, + ); + + // Keep the return values compatible with components using this hook + const galleries = data?.myGalleries ?? []; + const isLoading = Boolean(token) && loading; + + // Handles errors + const errorMessage = !token + ? "You must be logged in" + : (error?.message ?? ""); + + // Manually refresh gallery data, bypassing the cached result function refetchGalleries() { - setRefreshKey((prev) => prev + 1); + if (!token) return; + + void refetch(); } - return { galleries, isLoading, error, refetchGalleries }; + // Return values + return { + galleries, + isLoading, + error: errorMessage, + refetchGalleries, + }; } From 1147c63069f459556b4f1ce1a22117fa3c3823b7 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 20:37:22 +0100 Subject: [PATCH 03/17] feat: clear Apollo cache on authentication changes --- frontend/src/lib/syncApolloAuth.ts | 15 +++++++++++++++ frontend/src/main.tsx | 3 +++ 2 files changed, 18 insertions(+) create mode 100644 frontend/src/lib/syncApolloAuth.ts diff --git a/frontend/src/lib/syncApolloAuth.ts b/frontend/src/lib/syncApolloAuth.ts new file mode 100644 index 0000000..30fee47 --- /dev/null +++ b/frontend/src/lib/syncApolloAuth.ts @@ -0,0 +1,15 @@ +import { apolloClient } from "./apolloClient"; +import { useAuthStore } from "../store/authStore"; + +// Clear cached GraphQL data whenever the authenticated identity changes +export function syncApolloAuth() { + let previousToken = useAuthStore.getState().token; + + return useAuthStore.subscribe((state) => { + if (state.token === previousToken) return; + + previousToken = state.token; + + void apolloClient.clearStore(); + }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 74e50af..ba06348 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,10 +4,13 @@ import { BrowserRouter } from "react-router-dom"; import { ApolloProvider } from "@apollo/client/react"; import { apolloClient } from "./lib/apolloClient"; +import { syncApolloAuth } from "./lib/syncApolloAuth"; import "./index.css"; import App from "./App.tsx"; +syncApolloAuth(); + createRoot(document.getElementById("root")!).render( From 84b27eee4ad987e4e53cd77af679490e5e2fe055 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 20:45:01 +0100 Subject: [PATCH 04/17] fix: secure Apollo cache on authentication changes --- frontend/src/lib/apolloClient.ts | 45 ++++++++++++++++++++++-------- frontend/src/lib/syncApolloAuth.ts | 19 ++++++++++--- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/frontend/src/lib/apolloClient.ts b/frontend/src/lib/apolloClient.ts index b749101..5e5eb2d 100644 --- a/frontend/src/lib/apolloClient.ts +++ b/frontend/src/lib/apolloClient.ts @@ -1,23 +1,44 @@ import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { useAuthStore } from "../store/authStore"; -const httpLink = new HttpLink({ - uri: `${import.meta.env.BASE_URL}graphql`, +// Track authentication changes, including logout and account switching +let authVersion = 0; + +export function invalidateApolloAuthSession() { + authVersion += 1; +} + +// Add authentication headers and reject responses from old sessions +function fetchWithAuth( + uri: RequestInfo | URL, + options?: RequestInit, +): Promise { + const token = useAuthStore.getState().token; + const requestAuthVersion = authVersion; - fetch: (uri, options) => { - const token = useAuthStore.getState().token; + const headers = new Headers(options?.headers); - const headers = new Headers(options?.headers); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } else { + headers.delete("Authorization"); + } - if (token) { - headers.set("Authorization", `Bearer ${token}`); + return fetch(uri, { + ...options, + headers, + }).then((response) => { + if (requestAuthVersion !== authVersion) { + throw new Error("Authentication changed during the request"); } - return fetch(uri, { - ...options, - headers, - }); - }, + return response; + }); +} + +const httpLink = new HttpLink({ + uri: `${import.meta.env.BASE_URL}graphql`, + fetch: fetchWithAuth, }); export const apolloClient = new ApolloClient({ diff --git a/frontend/src/lib/syncApolloAuth.ts b/frontend/src/lib/syncApolloAuth.ts index 30fee47..b2ad12f 100644 --- a/frontend/src/lib/syncApolloAuth.ts +++ b/frontend/src/lib/syncApolloAuth.ts @@ -1,15 +1,26 @@ -import { apolloClient } from "./apolloClient"; +import { apolloClient, invalidateApolloAuthSession } from "./apolloClient"; import { useAuthStore } from "../store/authStore"; -// Clear cached GraphQL data whenever the authenticated identity changes +// Clear cached GraphQL data when the authentication session changes export function syncApolloAuth() { + let previousUserId = useAuthStore.getState().user?.id; let previousToken = useAuthStore.getState().token; return useAuthStore.subscribe((state) => { - if (state.token === previousToken) return; + const currentUserId = state.user?.id; + const currentToken = state.token; - previousToken = state.token; + if (currentUserId === previousUserId && currentToken === previousToken) { + return; + } + previousUserId = currentUserId; + previousToken = currentToken; + + // Reject responses from requests started in the previous session + invalidateApolloAuthSession(); + + // Remove cached data without automatically refetching active queries void apolloClient.clearStore(); }); } From 5446e213942cee04eeae51142460a8b64d00be05 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 21:00:02 +0100 Subject: [PATCH 05/17] refactor: migrate gallery creation to Apollo Client --- .../components/gallery/CreateGalleryModal.tsx | 47 ++--------------- .../src/hooks/gallery/useCreateGallery.ts | 50 +++++++++++++++++++ frontend/src/pages/ProfilePage.tsx | 7 +-- 3 files changed, 55 insertions(+), 49 deletions(-) create mode 100644 frontend/src/hooks/gallery/useCreateGallery.ts diff --git a/frontend/src/components/gallery/CreateGalleryModal.tsx b/frontend/src/components/gallery/CreateGalleryModal.tsx index cf84f35..9667962 100644 --- a/frontend/src/components/gallery/CreateGalleryModal.tsx +++ b/frontend/src/components/gallery/CreateGalleryModal.tsx @@ -1,71 +1,30 @@ import { LucideX } from "lucide-react"; import { useState } from "react"; -import { useAuthStore } from "../../store/authStore"; +import { useCreateGallery } from "../../hooks/gallery/useCreateGallery"; type CreateGalleryModalProps = { onClose: () => void; - onCreated: () => void; }; export default function CreateGalleryModal({ onClose, - onCreated, }: CreateGalleryModalProps) { const [title, setTitle] = useState(""); - const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(""); - const token = useAuthStore((state) => state.token); + const { createGallery, isCreating } = useCreateGallery(); async function handleCreateGallery() { if (!title.trim() || isCreating) return; - setIsCreating(true); setError(""); - const query = ` - mutation CreateGallery($title: String!) { - createGallery(title: $title) { - id - userId - title - artworkIds - } - } - `; - try { - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token ?? ""}`, - }, - body: JSON.stringify({ - query, - variables: { title: title.trim() }, - }), - }); - - if (!response.ok) { - throw new Error("Could not create gallery"); - } - - const result = await response.json(); - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } + await createGallery(title); - if (!result.data?.createGallery) { - throw new Error("Gallery was not created"); - } - onCreated(); onClose(); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong"); - } finally { - setIsCreating(false); } } diff --git a/frontend/src/hooks/gallery/useCreateGallery.ts b/frontend/src/hooks/gallery/useCreateGallery.ts new file mode 100644 index 0000000..78fbdea --- /dev/null +++ b/frontend/src/hooks/gallery/useCreateGallery.ts @@ -0,0 +1,50 @@ +import { gql } from "@apollo/client"; +import { useMutation } from "@apollo/client/react"; +import type { Gallery } from "../../types/Gallery"; + +type CreateGalleryData = { + createGallery: Gallery; +}; + +type CreateGalleryVariables = { + title: string; +}; + +const CREATE_GALLERY = gql` + mutation CreateGallery($title: String!) { + createGallery(title: $title) { + id + userId + title + artworkIds + } + } +`; + +// Create a gallery and refresh the gallery list +export function useCreateGallery() { + const [createGalleryMutation, { loading }] = useMutation< + CreateGalleryData, + CreateGalleryVariables + >(CREATE_GALLERY, { + refetchQueries: ["GetMyGalleries"], + awaitRefetchQueries: true, + }); + + async function createGallery(title: string): Promise { + const { data } = await createGalleryMutation({ + variables: { title: title.trim() }, + }); + + if (!data?.createGallery) { + throw new Error("Gallery was not created"); + } + + return data.createGallery; + } + + return { + createGallery, + isCreating: loading, + }; +} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 6102d28..a06050e 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -59,7 +59,7 @@ export default function ProfilePage() { const [showCreateGallery, setShowCreateGallery] = useState(false); // Galleries - const { galleries, isLoading, error, refetchGalleries } = useMyGalleries(); + const { galleries, isLoading, error } = useMyGalleries(); const galleryCoverImages = useGalleryCoverImages(galleries); // Profile image hooks @@ -151,10 +151,7 @@ export default function ProfilePage() { {showCreateGallery && ( - setShowCreateGallery(false)} - onCreated={refetchGalleries} - /> + setShowCreateGallery(false)} /> )}
{isLoading ? ( From d4dfc13ca26b6a0be6d19a9dd5fd107dfa46c405 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 21:22:46 +0100 Subject: [PATCH 06/17] refactor: migrate gallery deletion to Apollo Client --- .../src/hooks/gallery/useDeleteGallery.ts | 66 +++++++++---------- frontend/src/hooks/gallery/useMyGallery.ts | 15 ++--- 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/frontend/src/hooks/gallery/useDeleteGallery.ts b/frontend/src/hooks/gallery/useDeleteGallery.ts index 8f18523..102510c 100644 --- a/frontend/src/hooks/gallery/useDeleteGallery.ts +++ b/frontend/src/hooks/gallery/useDeleteGallery.ts @@ -1,21 +1,39 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useMutation } from "@apollo/client/react"; import { useState } from "react"; import { useAuthStore } from "../../store/authStore"; -type DeleteGalleryResponse = { - data?: { - deleteGallery: string; - }; - errors?: { message: string }[]; +type DeleteGalleryData = { + deleteGallery: string; }; +type DeleteGalleryVariables = { + galleryId: string; +}; + +const DELETE_GALLERY: TypedDocumentNode< + DeleteGalleryData, + DeleteGalleryVariables +> = gql` + mutation DeleteGallery($galleryId: ID!) { + deleteGallery(galleryId: $galleryId) + } +`; + +// Delete a gallery and refresh the cached gallery list export function useDeleteGallery() { const token = useAuthStore((state) => state.token); - - const [deleting, setDeleting] = useState(false); const [error, setError] = useState(""); + const [deleteGalleryMutation, { loading: deleting }] = useMutation( + DELETE_GALLERY, + { + refetchQueries: ["GetMyGalleries"], + awaitRefetchQueries: true, + }, + ); + async function deleteGallery(galleryId: string): Promise { - setDeleting(true); setError(""); try { @@ -23,42 +41,18 @@ export function useDeleteGallery() { throw new Error("You must be logged in"); } - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: ` - mutation DeleteGallery($galleryId: ID!) { - deleteGallery(galleryId: $galleryId) - } - `, - variables: { galleryId }, - }), + const { data } = await deleteGalleryMutation({ + variables: { galleryId }, }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const result = (await response.json()) as DeleteGalleryResponse; - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - if (!result.data?.deleteGallery) { + if (!data?.deleteGallery) { throw new Error("Could not delete gallery"); } - return result.data.deleteGallery; + return data.deleteGallery; } catch (err) { setError(err instanceof Error ? err.message : "Could not delete gallery"); return null; - } finally { - setDeleting(false); } } diff --git a/frontend/src/hooks/gallery/useMyGallery.ts b/frontend/src/hooks/gallery/useMyGallery.ts index 880594b..f8f086f 100644 --- a/frontend/src/hooks/gallery/useMyGallery.ts +++ b/frontend/src/hooks/gallery/useMyGallery.ts @@ -1,4 +1,4 @@ -import { gql } from "@apollo/client"; +import { gql, type TypedDocumentNode } from "@apollo/client"; import { useAuthStore } from "../../store/authStore"; import { useQuery } from "@apollo/client/react"; @@ -10,7 +10,7 @@ type MyGalleriesData = { }; // GraphQL query -const GET_MY_GALLERIES = gql` +const GET_MY_GALLERIES: TypedDocumentNode = gql` query GetMyGalleries { myGalleries { id @@ -28,13 +28,10 @@ export function useMyGalleries() { const token = useAuthStore((state) => state.token); // Skip unauthenticated requests and reuse cached results when available - const { data, loading, error, refetch } = useQuery( - GET_MY_GALLERIES, - { - skip: !token, - fetchPolicy: "cache-first", - }, - ); + const { data, loading, error, refetch } = useQuery(GET_MY_GALLERIES, { + skip: !token, + fetchPolicy: "cache-first", + }); // Keep the return values compatible with components using this hook const galleries = data?.myGalleries ?? []; From 39973db75701b99bb9c91c2966d440cdf973e837 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 21:38:26 +0100 Subject: [PATCH 07/17] refactor: migrate gallery renaming to Apollo Client --- .../hooks/gallery/useRenameGalleryMutation.ts | 78 +++++++++---------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/frontend/src/hooks/gallery/useRenameGalleryMutation.ts b/frontend/src/hooks/gallery/useRenameGalleryMutation.ts index edca2db..cd8a10e 100644 --- a/frontend/src/hooks/gallery/useRenameGalleryMutation.ts +++ b/frontend/src/hooks/gallery/useRenameGalleryMutation.ts @@ -1,25 +1,49 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useMutation } from "@apollo/client/react"; import { useState } from "react"; import { useAuthStore } from "../../store/authStore"; import type { Gallery } from "../../types/Gallery"; -type RenameGalleryResponse = { - data?: { - renameGallery: Gallery; - }; - errors?: { message: string }[]; +type RenameGalleryData = { + renameGallery: Gallery; }; +type RenameGalleryVariables = { + galleryId: string; + title: string; +}; + +const RENAME_GALLERY: TypedDocumentNode< + RenameGalleryData, + RenameGalleryVariables +> = gql` + mutation RenameGallery($galleryId: ID!, $title: String!) { + renameGallery(galleryId: $galleryId, title: $title) { + id + userId + title + artworkIds + } + } +`; + +// Rename a gallery and refresh the cached gallery list export function useRenameGalleryMutation() { const token = useAuthStore((state) => state.token); - - const [renaming, setRenaming] = useState(false); const [error, setError] = useState(""); + const [renameGalleryMutation, { loading: renaming }] = useMutation( + RENAME_GALLERY, + { + refetchQueries: ["GetMyGalleries"], + awaitRefetchQueries: true, + }, + ); + async function renameGallery( galleryId: string, title: string, ): Promise { - setRenaming(true); setError(""); try { @@ -27,47 +51,17 @@ export function useRenameGalleryMutation() { throw new Error("You must be logged in"); } - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: ` - mutation RenameGallery($galleryId: ID!, $title: String!) { - renameGallery(galleryId: $galleryId, title: $title) { - id - userId - title - artworkIds - } - } - `, - variables: { galleryId, title }, - }), + const { data } = await renameGalleryMutation({ + variables: { galleryId, title }, }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const result = (await response.json()) as RenameGalleryResponse; - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - if (!result.data?.renameGallery) { + if (!data?.renameGallery) { throw new Error("Could not rename gallery"); } - - return result.data.renameGallery; + return data.renameGallery; } catch (err) { setError(err instanceof Error ? err.message : "Could not rename gallery"); return null; - } finally { - setRenaming(false); } } From 3c9d8d7d1f4fedce3cdcc29f4ff0cc756e796799 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Wed, 11 Nov 2026 10:21:52 +0100 Subject: [PATCH 08/17] refactor: migrate artwork removal to Apollo Client --- .../gallery/useRemoveArtworkFromGallery.ts | 73 +++++++++---------- 1 file changed, 33 insertions(+), 40 deletions(-) diff --git a/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts b/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts index 8da2490..ffc8193 100644 --- a/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts +++ b/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts @@ -1,20 +1,44 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useMutation } from "@apollo/client/react"; import { useState } from "react"; import { useAuthStore } from "../../store/authStore"; import type { Gallery } from "../../types/Gallery"; -type RemoveArtworkResponse = { - data?: { - removeArtworkFromGallery: Gallery; - }; - errors?: { message: string }[]; +type RemoveArtworkData = { + removeArtworkFromGallery: Gallery; }; +type RemoveArtworkVariables = { + galleryId: string; + artworkId: number; +}; + +const REMOVE_ARTWORK: TypedDocumentNode< + RemoveArtworkData, + RemoveArtworkVariables +> = gql` + mutation RemoveArtwork($galleryId: ID!, $artworkId: Int!) { + removeArtworkFromGallery(galleryId: $galleryId, artworkId: $artworkId) { + id + userId + title + artworkIds + } + } +`; + +// Remove an artwork from a gallery and refresh cached gallery data export function useRemoveArtworkFromGallery() { const token = useAuthStore((state) => state.token); const [removingId, setRemovingId] = useState(null); const [error, setError] = useState(""); + const [removeArtworkMutation] = useMutation(REMOVE_ARTWORK, { + refetchQueries: ["GetMyGalleries"], + awaitRefetchQueries: true, + }); + async function removeArtwork( galleryId: string, artworkId: number, @@ -27,45 +51,14 @@ export function useRemoveArtworkFromGallery() { throw new Error("You must be logged in"); } - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: ` - mutation RemoveArtwork($galleryId: ID!, $artworkId: Int!) { - removeArtworkFromGallery( - galleryId: $galleryId - artworkId: $artworkId - ) { - id - userId - title - artworkIds - } - } - `, - variables: { galleryId, artworkId }, - }), + const { data } = await removeArtworkMutation({ + variables: { galleryId, artworkId }, }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const result = (await response.json()) as RemoveArtworkResponse; - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - if (!result.data?.removeArtworkFromGallery) { + if (!data?.removeArtworkFromGallery) { throw new Error("Could not remove artwork"); } - - return result.data.removeArtworkFromGallery; + return data.removeArtworkFromGallery; } catch (err) { setError(err instanceof Error ? err.message : "Could not remove artwork"); return null; From 6bd8648b092181ddd311b13f3c8e5deb666d2d8a Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Wed, 11 Nov 2026 10:32:53 +0100 Subject: [PATCH 09/17] refactor: migrate adding artworks to galleries to Apollo Client --- .../components/gallery/AddToGalleryModal.tsx | 133 +++--------------- .../hooks/gallery/useAddArtworkToGallery.ts | 66 +++++++++ 2 files changed, 86 insertions(+), 113 deletions(-) create mode 100644 frontend/src/hooks/gallery/useAddArtworkToGallery.ts diff --git a/frontend/src/components/gallery/AddToGalleryModal.tsx b/frontend/src/components/gallery/AddToGalleryModal.tsx index c7dd44c..0b513e2 100644 --- a/frontend/src/components/gallery/AddToGalleryModal.tsx +++ b/frontend/src/components/gallery/AddToGalleryModal.tsx @@ -1,132 +1,38 @@ -import { useEffect, useState } from "react"; -import type { Gallery } from "../../types/Gallery"; -import { useAuthStore } from "../../store/authStore"; +import { useState } from "react"; +import { useMyGalleries } from "../../hooks/gallery/useMyGallery"; +import { useAddArtworkToGallery } from "../../hooks/gallery/useAddArtworkToGallery"; type AddToGalleryModalProps = { artworkId: number; onClose: () => void; }; -type GalleriesResponse = { - data?: { - myGalleries: Gallery[]; - }; - errors?: { message: string }[]; -}; - -type AddArtworkResponse = { - data?: { - addArtworkToGallery: Gallery; - }; - errors?: { message: string }[]; -}; - export default function AddToGalleryModal({ artworkId, onClose, }: AddToGalleryModalProps) { - const [galleries, setGalleries] = useState([]); - const [loading, setLoading] = useState(true); - const [message, setMessage] = useState(""); - - const token = useAuthStore((state) => state.token); - - useEffect(() => { - async function fetchGalleries() { - const query = ` - query { - myGalleries { - id - userId - title - artworkIds - } - } - `; - - try { - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token ?? ""}`, - }, - body: JSON.stringify({ query }), - }); - - if (!response.ok) { - throw new Error("Could not fetch galleries"); - } + const { galleries, isLoading, error: galleriesError } = useMyGalleries(); - const result = (await response.json()) as GalleriesResponse; + const { addArtwork, adding, error: addError } = useAddArtworkToGallery(); - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - setGalleries(result.data?.myGalleries ?? []); - } catch (error) { - setMessage( - error instanceof Error ? error.message : "Something went wrong", - ); - } finally { - setLoading(false); - } - } - - void fetchGalleries(); - }, [token]); + const [message, setMessage] = useState(""); async function handleAddToGallery(galleryId: string) { - const mutation = ` - mutation AddArtworkToGallery($galleryId: ID!, $artworkId: Int!) { - addArtworkToGallery( - galleryId: $galleryId - artworkId: $artworkId - ) { - id - title - artworkIds - } - } - `; + if (adding) return; - try { - setMessage(""); + setMessage(""); - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token ?? ""}`, - }, - body: JSON.stringify({ - query: mutation, - variables: { - galleryId, - artworkId, - }, - }), - }); - - if (!response.ok) { - throw new Error("Could not add artwork to gallery"); - } - - const result = (await response.json()) as AddArtworkResponse; - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } + const updatedGallery = await addArtwork(galleryId, artworkId); + if (updatedGallery) { onClose(); - } catch (error) { - setMessage( - error instanceof Error ? error.message : "Something went wrong", - ); + } else { + setMessage("Could not add artwork to gallery"); } } + const errorMessage = message || addError || galleriesError; + return (
- {loading ? ( + {isLoading ? (

Loading galleries...

- ) : galleries.length === 0 ? ( + ) : galleries.length === 0 && !galleriesError ? (

You have not created any galleries yet.

@@ -164,8 +70,9 @@ export default function AddToGalleryModal({ @@ -173,9 +80,9 @@ export default function AddToGalleryModal({
)} - {message && ( + {errorMessage && (

- {message} + {errorMessage}

)}
diff --git a/frontend/src/hooks/gallery/useAddArtworkToGallery.ts b/frontend/src/hooks/gallery/useAddArtworkToGallery.ts new file mode 100644 index 0000000..325dfa7 --- /dev/null +++ b/frontend/src/hooks/gallery/useAddArtworkToGallery.ts @@ -0,0 +1,66 @@ +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useMutation } from "@apollo/client/react"; +import { useState } from "react"; +import { useAuthStore } from "../../store/authStore"; +import type { Gallery } from "../../types/Gallery"; + +type AddArtworkData = { + addArtworkToGallery: Gallery; +}; + +type AddArtworkVariables = { + galleryId: string; + artworkId: number; +}; + +const ADD_ARTWORK: TypedDocumentNode = gql` + mutation AddArtworkToGallery($galleryId: ID!, $artworkId: Int!) { + addArtworkToGallery(galleryId: $galleryId, artworkId: $artworkId) { + id + userId + title + artworkIds + } + } +`; + +// Add an artwork to a gallery and refresh the cached gallery list +export function useAddArtworkToGallery() { + const token = useAuthStore((state) => state.token); + const [error, setError] = useState(""); + + const [addArtworkMutation, { loading: adding }] = useMutation(ADD_ARTWORK, { + refetchQueries: ["GetMyGalleries"], + awaitRefetchQueries: true, + }); + + async function addArtwork( + galleryId: string, + artworkId: number, + ): Promise { + setError(""); + + try { + if (!token) { + throw new Error("You must be logged in"); + } + + const { data } = await addArtworkMutation({ + variables: { galleryId, artworkId }, + }); + + if (!data?.addArtworkToGallery) { + throw new Error("Could not add artwork to gallery"); + } + + return data.addArtworkToGallery; + } catch (err) { + setError( + err instanceof Error ? err.message : "Could not add artwork to gallery", + ); + return null; + } + } + + return { addArtwork, adding, error }; +} From 177750090e7514c718e4d7c19af097664c804d2a Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Wed, 11 Nov 2026 11:23:05 +0100 Subject: [PATCH 10/17] fix: invalidate Apollo gallery cache after artwork mutations --- .../hooks/gallery/useAddArtworkToGallery.ts | 15 +- frontend/src/hooks/gallery/useGallery.ts | 132 ++++++------------ .../gallery/useRemoveArtworkFromGallery.ts | 12 +- .../hooks/gallery/useRenameGalleryMutation.ts | 2 +- frontend/src/lib/apolloClient.ts | 8 +- frontend/src/pages/GalleryPage.tsx | 10 +- frontend/src/test/GalleryPage.test.tsx | 125 ++++++++++------- 7 files changed, 147 insertions(+), 157 deletions(-) diff --git a/frontend/src/hooks/gallery/useAddArtworkToGallery.ts b/frontend/src/hooks/gallery/useAddArtworkToGallery.ts index 325dfa7..578df27 100644 --- a/frontend/src/hooks/gallery/useAddArtworkToGallery.ts +++ b/frontend/src/hooks/gallery/useAddArtworkToGallery.ts @@ -24,14 +24,24 @@ const ADD_ARTWORK: TypedDocumentNode = gql` } `; -// Add an artwork to a gallery and refresh the cached gallery list +// Add an artwork to a gallery and invalidate cached gallery details export function useAddArtworkToGallery() { const token = useAuthStore((state) => state.token); const [error, setError] = useState(""); const [addArtworkMutation, { loading: adding }] = useMutation(ADD_ARTWORK, { - refetchQueries: ["GetMyGalleries"], + refetchQueries: ["GetMyGalleries", "GetGallery"], awaitRefetchQueries: true, + + update(cache) { + // Remove cached gallery queries, including previously visited pages. + cache.evict({ + id: "ROOT_QUERY", + fieldName: "gallery", + }); + + cache.gc(); + }, }); async function addArtwork( @@ -58,6 +68,7 @@ export function useAddArtworkToGallery() { setError( err instanceof Error ? err.message : "Could not add artwork to gallery", ); + return null; } } diff --git a/frontend/src/hooks/gallery/useGallery.ts b/frontend/src/hooks/gallery/useGallery.ts index 1115d39..f401caf 100644 --- a/frontend/src/hooks/gallery/useGallery.ts +++ b/frontend/src/hooks/gallery/useGallery.ts @@ -1,102 +1,52 @@ -import { useEffect, useState } from "react"; +import { gql, type TypedDocumentNode } from "@apollo/client"; +import { useQuery } from "@apollo/client/react"; import type { GalleryPage } from "../../types/Gallery"; import { useAuthStore } from "../../store/authStore"; -type GalleryResponse = { - data?: { - gallery: GalleryPage | null; - }; - errors?: { message: string }[]; +type GalleryData = { + gallery: GalleryPage | null; }; -export function useGallery( - id: string, - page: number, - pageSize = 12, - refreshKey = 0, -) { - const token = useAuthStore((state) => state.token); - - const [gallery, setGallery] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - - useEffect(() => { - const controller = new AbortController(); - - async function fetchGallery() { - setLoading(true); - setError(""); - setGallery(null); - - try { - if (!token) { - throw new Error("You must be logged in"); - } - - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - signal: controller.signal, - body: JSON.stringify({ - query: ` - query GetGallery($id: ID!, $limit: Int!, $offset: Int!) { - gallery(id: $id, limit: $limit, offset: $offset) { - id - title - totalCount - artworks { - id - title - artist_display - image_url - } - } - } - `, - variables: { - id, - limit: pageSize, - offset: (page - 1) * pageSize, - }, - }), - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const result = (await response.json()) as GalleryResponse; - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - if (!result.data) { - throw new Error("Could not load gallery"); - } - - if (!controller.signal.aborted) { - setGallery(result.data.gallery); - } - } catch (err) { - if (controller.signal.aborted) return; +type GalleryVariables = { + id: string; + limit: number; + offset: number; +}; - setError(err instanceof Error ? err.message : "Could not load gallery"); - } finally { - if (!controller.signal.aborted) { - setLoading(false); - } +const GET_GALLERY: TypedDocumentNode = gql` + query GetGallery($id: ID!, $limit: Int!, $offset: Int!) { + gallery(id: $id, limit: $limit, offset: $offset) { + id + title + totalCount + artworks { + id + title + artist_display + image_url } } + } +`; - void fetchGallery(); - - return () => controller.abort(); - }, [id, page, pageSize, token, refreshKey]); +// Fetch and cache paginated gallery details +export function useGallery(id: string, page: number, pageSize = 12) { + const token = useAuthStore((state) => state.token); - return { gallery, loading, error }; + const { data, loading, error } = useQuery(GET_GALLERY, { + variables: { + id, + limit: pageSize, + offset: (page - 1) * pageSize, + }, + skip: !token || !id, + fetchPolicy: "cache-first", + notifyOnNetworkStatusChange: true, + }); + + return { + gallery: data?.gallery ?? null, + loading: Boolean(token && id) && loading, + error: !token ? "You must be logged in" : (error?.message ?? ""), + }; } diff --git a/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts b/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts index ffc8193..dba56af 100644 --- a/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts +++ b/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts @@ -35,8 +35,18 @@ export function useRemoveArtworkFromGallery() { const [error, setError] = useState(""); const [removeArtworkMutation] = useMutation(REMOVE_ARTWORK, { - refetchQueries: ["GetMyGalleries"], + refetchQueries: ["GetMyGalleries", "GetGallery"], awaitRefetchQueries: true, + + update(cache) { + // Remove cached gallery queries, including previously visited pages. + cache.evict({ + id: "ROOT_QUERY", + fieldName: "gallery", + }); + + cache.gc(); + }, }); async function removeArtwork( diff --git a/frontend/src/hooks/gallery/useRenameGalleryMutation.ts b/frontend/src/hooks/gallery/useRenameGalleryMutation.ts index cd8a10e..8bbbb75 100644 --- a/frontend/src/hooks/gallery/useRenameGalleryMutation.ts +++ b/frontend/src/hooks/gallery/useRenameGalleryMutation.ts @@ -35,7 +35,7 @@ export function useRenameGalleryMutation() { const [renameGalleryMutation, { loading: renaming }] = useMutation( RENAME_GALLERY, { - refetchQueries: ["GetMyGalleries"], + refetchQueries: ["GetMyGalleries", "GetGallery"], awaitRefetchQueries: true, }, ); diff --git a/frontend/src/lib/apolloClient.ts b/frontend/src/lib/apolloClient.ts index 5e5eb2d..d5ced4b 100644 --- a/frontend/src/lib/apolloClient.ts +++ b/frontend/src/lib/apolloClient.ts @@ -43,7 +43,13 @@ const httpLink = new HttpLink({ export const apolloClient = new ApolloClient({ link: httpLink, - cache: new InMemoryCache(), + cache: new InMemoryCache({ + typePolicies: { + GalleryPage: { + keyFields: false, + }, + }, + }), defaultOptions: { watchQuery: { fetchPolicy: "cache-first", diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 2dd36b8..c9c097f 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -27,7 +27,6 @@ export default function GalleryPage() { // State const [page, setPage] = useState(1); - const [refreshKey, setRefreshKey] = useState(0); const [isEditing, setIsEditing] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -35,12 +34,7 @@ export default function GalleryPage() { const { successMessage, setSuccessMessage } = useSuccessMessage(); const { deleteGallery, deleting, error: deleteError } = useDeleteGallery(); - const { gallery, loading, error } = useGallery( - id, - page, - PAGE_SIZE, - refreshKey, - ); + const { gallery, loading, error } = useGallery(id, page, PAGE_SIZE); const { isRenaming, @@ -53,7 +47,6 @@ export default function GalleryPage() { handleCancelRenaming, handleRename, } = useGalleryRename(id, () => { - setRefreshKey((current) => current + 1); setSuccessMessage("Gallery name updated."); }); @@ -94,7 +87,6 @@ export default function GalleryPage() { ); setPage((currentPage) => Math.min(currentPage, lastPage)); - setRefreshKey((current) => current + 1); setSuccessMessage(`"${artwork.title}" was removed from this gallery.`); } diff --git a/frontend/src/test/GalleryPage.test.tsx b/frontend/src/test/GalleryPage.test.tsx index 87eb3a1..4c4b993 100644 --- a/frontend/src/test/GalleryPage.test.tsx +++ b/frontend/src/test/GalleryPage.test.tsx @@ -99,6 +99,7 @@ describe("GalleryPage", () => { expect( screen.getByRole("status", { name: "Gallery loading" }), ).toHaveTextContent("Loading gallery..."); + expect( screen.queryByRole("heading", { name: "Gallery not found" }), ).not.toBeInTheDocument(); @@ -120,6 +121,7 @@ describe("GalleryPage", () => { expect( screen.queryByRole("status", { name: "Gallery loading" }), ).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); @@ -234,14 +236,14 @@ describe("GalleryPage", () => { await user.click(screen.getByRole("button", { name: "Next" })); - expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 2, 12, 0); + expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 2, 12); expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled(); await user.click(screen.getByRole("button", { name: "Previous" })); - expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12, 0); + expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12); expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(); @@ -282,34 +284,36 @@ describe("GalleryPage", () => { expect( screen.queryByRole("button", { name: removeName }), ).not.toBeInTheDocument(); + expect(mockRemoveArtwork).not.toHaveBeenCalled(); }); - it("removes an artwork and refreshes the gallery", async () => { + it("removes an artwork and displays updated gallery data", async () => { const user = userEvent.setup(); + let artworkRemoved = false; - mockedUseGallery.mockImplementation((...args) => { - const refreshed = args[3] !== 0; + mockedUseGallery.mockImplementation(() => ({ + gallery: { + id: "test-gallery", + title: "My gallery", + totalCount: artworkRemoved ? 0 : 1, + artworks: artworkRemoved ? [] : [{ id: 1, title: "Test artwork" }], + }, + loading: false, + error: "", + })); + + mockRemoveArtwork.mockImplementation(async () => { + artworkRemoved = true; return { - gallery: { - id: "test-gallery", - title: "My gallery", - totalCount: refreshed ? 0 : 1, - artworks: refreshed ? [] : [{ id: 1, title: "Test artwork" }], - }, - loading: false, - error: "", + id: "test-gallery", + userId: "owner", + title: "My gallery", + artworkIds: [], }; }); - mockRemoveArtwork.mockResolvedValue({ - id: "test-gallery", - userId: "owner", - title: "My gallery", - artworkIds: [], - }); - renderGalleryPage(); await user.click(screen.getByRole("button", { name: "Edit gallery" })); @@ -328,12 +332,17 @@ describe("GalleryPage", () => { await screen.findByText('"Test artwork" was removed from this gallery.'), ).toBeInTheDocument(); - expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12, 1); + // Simulate updated Apollo query data on the next render. + await user.click(screen.getByRole("button", { name: "Done" })); + + expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12); expect(screen.getByText("0 artworks")).toBeInTheDocument(); + expect( screen.getByText("This gallery has no artworks yet."), ).toBeInTheDocument(); + expect( screen.queryByRole("heading", { name: "Test artwork" }), ).not.toBeInTheDocument(); @@ -372,9 +381,10 @@ describe("GalleryPage", () => { expect( screen.getByRole("heading", { name: "Test artwork" }), ).toBeInTheDocument(); + expect(screen.getByText("1 artwork")).toBeInTheDocument(); - expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12, 0); + expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12); expect( screen.queryByText('"Test artwork" was removed from this gallery.'), @@ -408,9 +418,11 @@ describe("GalleryPage", () => { expect( screen.getByRole("heading", { name: "My gallery" }), ).toBeInTheDocument(); + expect( screen.getByRole("heading", { name: "Test artwork" }), ).toBeInTheDocument(); + expect(screen.getByText("1 artwork")).toBeInTheDocument(); }); @@ -444,6 +456,7 @@ describe("GalleryPage", () => { const firstButton = screen.getByRole("button", { name: "Remove First artwork from this gallery", }); + const secondButton = screen.getByRole("button", { name: "Remove Second artwork from this gallery", }); @@ -465,32 +478,33 @@ describe("GalleryPage", () => { title: `Artwork ${index + 1}`, })); - mockedUseGallery.mockImplementation((...args) => { - const page = args[1]; - const refreshed = args[3] !== 0; + let artworkRemoved = false; + + mockedUseGallery.mockImplementation((_id, page) => ({ + gallery: { + id: "test-gallery", + title: "My gallery", + totalCount: artworkRemoved ? 12 : 13, + artworks: + page === 2 && !artworkRemoved + ? [{ id: 13, title: "Last artwork" }] + : firstPageArtworks, + }, + loading: false, + error: "", + })); + + mockRemoveArtwork.mockImplementation(async () => { + artworkRemoved = true; return { - gallery: { - id: "test-gallery", - title: "My gallery", - totalCount: refreshed ? 12 : 13, - artworks: - page === 2 && !refreshed - ? [{ id: 13, title: "Last artwork" }] - : firstPageArtworks, - }, - loading: false, - error: "", + id: "test-gallery", + userId: "owner", + title: "My gallery", + artworkIds: firstPageArtworks.map((artwork) => artwork.id), }; }); - mockRemoveArtwork.mockResolvedValue({ - id: "test-gallery", - userId: "owner", - title: "My gallery", - artworkIds: firstPageArtworks.map((artwork) => artwork.id), - }); - renderGalleryPage(); await user.click(screen.getByRole("button", { name: "Next" })); @@ -511,14 +525,16 @@ describe("GalleryPage", () => { expect(await screen.findByText("12 artworks")).toBeInTheDocument(); - expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12, 1); + expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12); expect( screen.queryByRole("heading", { name: "Last artwork" }), ).not.toBeInTheDocument(); + expect( screen.getByRole("heading", { name: "Artwork 1" }), ).toBeInTheDocument(); + expect( screen.queryByRole("button", { name: "Next" }), ).not.toBeInTheDocument(); @@ -582,11 +598,12 @@ describe("GalleryPage", () => { it("renames the gallery and shows success feedback", async () => { const user = userEvent.setup(); + let galleryRenamed = false; - mockedUseGallery.mockImplementation((...args) => ({ + mockedUseGallery.mockImplementation(() => ({ gallery: { id: "test-gallery", - title: args[3] === 0 ? "My gallery" : "Updated gallery", + title: galleryRenamed ? "Updated gallery" : "My gallery", totalCount: 0, artworks: [], }, @@ -594,11 +611,15 @@ describe("GalleryPage", () => { error: "", })); - mockRenameGallery.mockResolvedValue({ - id: "test-gallery", - userId: "owner", - title: "Updated gallery", - artworkIds: [], + mockRenameGallery.mockImplementation(async () => { + galleryRenamed = true; + + return { + id: "test-gallery", + userId: "owner", + title: "Updated gallery", + artworkIds: [], + }; }); renderGalleryPage(); @@ -735,7 +756,7 @@ describe("GalleryPage", () => { "Gallery name updated.", ); - expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12, 0); + expect(mockedUseGallery).toHaveBeenLastCalledWith("test-gallery", 1, 12); }); it("cancels gallery deletion without deleting the gallery", async () => { From ba9600b94cd58fb31d1343428ea76151852e8c53 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Wed, 11 Nov 2026 11:53:47 +0100 Subject: [PATCH 11/17] fix: invalidate Apollo cache after gallery rename and deletion --- frontend/src/hooks/gallery/useDeleteGallery.ts | 12 ++++++++++++ .../src/hooks/gallery/useRenameGalleryMutation.ts | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/frontend/src/hooks/gallery/useDeleteGallery.ts b/frontend/src/hooks/gallery/useDeleteGallery.ts index 102510c..c89e677 100644 --- a/frontend/src/hooks/gallery/useDeleteGallery.ts +++ b/frontend/src/hooks/gallery/useDeleteGallery.ts @@ -30,6 +30,18 @@ export function useDeleteGallery() { { refetchQueries: ["GetMyGalleries"], awaitRefetchQueries: true, + + update(cache, { data }) { + if (!data?.deleteGallery) return; + + // Remove cached gallery details, including inactive pages. + cache.evict({ + id: "ROOT_QUERY", + fieldName: "gallery", + }); + + cache.gc(); + }, }, ); diff --git a/frontend/src/hooks/gallery/useRenameGalleryMutation.ts b/frontend/src/hooks/gallery/useRenameGalleryMutation.ts index 8bbbb75..2fa2256 100644 --- a/frontend/src/hooks/gallery/useRenameGalleryMutation.ts +++ b/frontend/src/hooks/gallery/useRenameGalleryMutation.ts @@ -37,6 +37,16 @@ export function useRenameGalleryMutation() { { refetchQueries: ["GetMyGalleries", "GetGallery"], awaitRefetchQueries: true, + + update(cache) { + // Invalidate cached gallery details, including inactive queries. + cache.evict({ + id: "ROOT_QUERY", + fieldName: "gallery", + }); + + cache.gc(); + }, }, ); From 2b57d169d7c090178c4e21f23942b2665acc1f10 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Wed, 11 Nov 2026 12:29:39 +0100 Subject: [PATCH 12/17] refactor: migrate artwork and filter hooks to Apollo Client --- frontend/src/components/CatalogueControls.tsx | 350 ++++++++++++++---- frontend/src/hooks/artwork/useArtists.ts | 37 ++ frontend/src/hooks/artwork/useArtwork.ts | 42 +++ .../src/hooks/artwork/useArtworkFacets.ts | 118 ++++++ .../src/hooks/artwork/useArtworkOptions.ts | 29 ++ frontend/src/hooks/artwork/useArtworks.ts | 103 ++++++ frontend/src/hooks/artwork/useCountries.ts | 25 ++ frontend/src/hooks/useArtists.ts | 64 ---- frontend/src/hooks/useArtwork.ts | 73 ---- frontend/src/hooks/useArtworkFacets.ts | 142 ------- frontend/src/hooks/useArtworkOptions.ts | 50 --- frontend/src/hooks/useArtworks.ts | 121 ------ frontend/src/hooks/useCountries.ts | 48 --- frontend/src/pages/ArtCataloguePage.tsx | 2 +- frontend/src/pages/ArtworkDetailPage.tsx | 2 +- frontend/src/test/CatalogueControls.test.tsx | 2 +- 16 files changed, 626 insertions(+), 582 deletions(-) create mode 100644 frontend/src/hooks/artwork/useArtists.ts create mode 100644 frontend/src/hooks/artwork/useArtwork.ts create mode 100644 frontend/src/hooks/artwork/useArtworkFacets.ts create mode 100644 frontend/src/hooks/artwork/useArtworkOptions.ts create mode 100644 frontend/src/hooks/artwork/useArtworks.ts create mode 100644 frontend/src/hooks/artwork/useCountries.ts delete mode 100644 frontend/src/hooks/useArtists.ts delete mode 100644 frontend/src/hooks/useArtwork.ts delete mode 100644 frontend/src/hooks/useArtworkFacets.ts delete mode 100644 frontend/src/hooks/useArtworkOptions.ts delete mode 100644 frontend/src/hooks/useArtworks.ts delete mode 100644 frontend/src/hooks/useCountries.ts diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index a037dcf..fbd8a1c 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -1,7 +1,16 @@ -import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type SubmitEvent } from "react"; +import { + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type SubmitEvent, +} from "react"; import { CircleUserRound, Search, SlidersHorizontal } from "lucide-react"; import { Link } from "react-router-dom"; -import { useArtworkFacets, type ArtworkFacetOption } from "../hooks/useArtworkFacets"; +import { + useArtworkFacets, + type ArtworkFacetOption, +} from "../hooks/artwork/useArtworkFacets"; import { useThemeStore } from "../store/themeStore"; import { useAuthStore } from "../store/authStore"; import ThemeToggleButton from "./ThemeToggleButton"; @@ -38,20 +47,21 @@ type CatalogueControlsProps = { galleryVisible: boolean; }; -type FilterSection = "sort" | "artists" | "period" | "country" | "culture" | "artPeriod"; +type FilterSection = + "sort" | "artists" | "period" | "country" | "culture" | "artPeriod"; const sortOptions: { value: ArtworkSort | ""; label: string; }[] = [ - { value: "", label: "Default order" }, - { value: "TITLE_ASC", label: "Title: A-Z" }, - { value: "TITLE_DESC", label: "Title: Z-A" }, - { value: "ARTIST_ASC", label: "Artist: A-Z" }, - { value: "ARTIST_DESC", label: "Artist: Z-A" }, - { value: "YEAR_ASC", label: "Year: oldest first" }, - { value: "YEAR_DESC", label: "Year: newest first" }, - ]; + { value: "", label: "Default order" }, + { value: "TITLE_ASC", label: "Title: A-Z" }, + { value: "TITLE_DESC", label: "Title: Z-A" }, + { value: "ARTIST_ASC", label: "Artist: A-Z" }, + { value: "ARTIST_DESC", label: "Artist: Z-A" }, + { value: "YEAR_ASC", label: "Year: oldest first" }, + { value: "YEAR_DESC", label: "Year: newest first" }, +]; const periodOptions: { value: ArtworkPeriod | ""; label: string }[] = [ { value: "", label: "Any time" }, @@ -62,7 +72,10 @@ const periodOptions: { value: ArtworkPeriod | ""; label: string }[] = [ ]; function toFacetOptions(options: ArtworkFacetOption[]) { - return options.map(({ value, count }) => ({ value, label: `${value} (${count})` })); + return options.map(({ value, count }) => ({ + value, + label: `${value} (${count})`, + })); } function selectedFacetOptions(values: string[], options: ArtworkFacetOption[]) { @@ -128,14 +141,18 @@ export default function CatalogueControls({ const isLoggedIn = useAuthStore((state) => Boolean(state.token)); const [searchInput, setSearchInput] = useState(search); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); - const [openSections, setOpenSections] = useState>(() => new Set()); + const [openSections, setOpenSections] = useState>( + () => new Set(), + ); const [artistSearchInput, setArtistSearchInput] = useState(""); const [artistSearch, setArtistSearch] = useState(""); const [activeRangeThumb, setActiveRangeThumb] = useState<"from" | "to">("to"); const [draftFrom, setDraftFrom] = useState(dateFrom ?? minYear); const [draftTo, setDraftTo] = useState(dateTo ?? maxYear); const [isMobileLayout, setIsMobileLayout] = useState( - () => typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches, + () => + typeof window !== "undefined" && + window.matchMedia("(max-width: 1023px)").matches, ); const filterPanelRef = useRef(null); const desktopFilterButtonRef = useRef(null); @@ -172,24 +189,40 @@ export default function CatalogueControls({ }); } - const { options: facets, loading: facetsLoading, error: facetsError } = useArtworkFacets({ - search, - artistSearch, - artists: selectedArtists, - countries: selectedCountries, - cultures: selectedCultures, - artPeriods: selectedArtPeriods, - period, - dateFrom, - dateTo, - }, showFilters); - - const availablePeriodValues = new Set(facets.periods.map(({ value }) => value)); + const { + options: facets, + loading: facetsLoading, + error: facetsError, + } = useArtworkFacets( + { + search, + artistSearch, + artists: selectedArtists, + countries: selectedCountries, + cultures: selectedCultures, + artPeriods: selectedArtPeriods, + period, + dateFrom, + dateTo, + }, + showFilters, + ); + + const availablePeriodValues = new Set( + facets.periods.map(({ value }) => value), + ); const availablePeriodOptions = periodOptions - .filter((option) => !option.value || availablePeriodValues.has(option.value) || option.value === period) + .filter( + (option) => + !option.value || + availablePeriodValues.has(option.value) || + option.value === period, + ) .map((option) => { const facet = facets.periods.find(({ value }) => value === option.value); - return facet ? { ...option, label: `${option.label} (${facet.count})` } : option; + return facet + ? { ...option, label: `${option.label} (${facet.count})` } + : option; }); useEffect(() => { @@ -208,9 +241,14 @@ export default function CatalogueControls({ const wasOpen = previousShowFilters.current; previousShowFilters.current = showFilters; if (isMobileLayout && showFilters && !wasOpen) { - filterPanelRef.current?.querySelector("button:not(:disabled)")?.focus(); + filterPanelRef.current + ?.querySelector("button:not(:disabled)") + ?.focus(); } else if (!showFilters && wasOpen) { - (isMobileLayout ? mobileFilterButtonRef.current : desktopFilterButtonRef.current)?.focus(); + (isMobileLayout + ? mobileFilterButtonRef.current + : desktopFilterButtonRef.current + )?.focus(); } }, [isMobileLayout, showFilters]); @@ -233,9 +271,10 @@ export default function CatalogueControls({ function handleFilterPanelKeyDown(event: ReactKeyboardEvent) { if (event.key !== "Tab" || !showFilters || !isMobileLayout) return; - const focusableElements = filterPanelRef.current?.querySelectorAll( - 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', - ); + const focusableElements = + filterPanelRef.current?.querySelectorAll( + 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])', + ); if (!focusableElements?.length) { event.preventDefault(); return; @@ -243,10 +282,18 @@ export default function CatalogueControls({ const first = focusableElements[0]; const last = focusableElements[focusableElements.length - 1]; - if (event.shiftKey && (document.activeElement === first || !filterPanelRef.current?.contains(document.activeElement))) { + if ( + event.shiftKey && + (document.activeElement === first || + !filterPanelRef.current?.contains(document.activeElement)) + ) { event.preventDefault(); last.focus(); - } else if (!event.shiftKey && (document.activeElement === last || !filterPanelRef.current?.contains(document.activeElement))) { + } else if ( + !event.shiftKey && + (document.activeElement === last || + !filterPanelRef.current?.contains(document.activeElement)) + ) { event.preventDefault(); first.focus(); } @@ -274,7 +321,10 @@ export default function CatalogueControls({ // AI was primary used here to get the styling we wanted for the filter options. return (
-
+
{openSections.has("sort") && (
- + onCultureChange(options.map((option) => option.value))} + value={selectedFacetOptions( + selectedCultures, + facets.cultures, + )} + onChange={(options) => + onCultureChange(options.map((option) => option.value)) + } isMulti isClearable isLoading={facetsLoading} isDisabled={facetsLoading} placeholder="Search or select a culture..." loadingMessage={() => "Updating available cultures..."} - noOptionsMessage={() => facetsLoading ? "Updating available cultures..." : "No cultures with matching artworks"} + noOptionsMessage={() => + facetsLoading + ? "Updating available cultures..." + : "No cultures with matching artworks" + } styles={{ menu: (base) => ({ ...base, zIndex: 10 }) }} theme={(theme) => selectTheme(theme, isDarkMode)} /> @@ -467,25 +551,43 @@ export default function CatalogueControls({ aria-expanded={openSections.has("artPeriod")} onClick={() => toggleSection("artPeriod")} > - Art periods