diff --git a/__tests__/components/projects/project-card.test.tsx b/__tests__/components/projects/project-card.test.tsx index f1bfe57b..61635868 100644 --- a/__tests__/components/projects/project-card.test.tsx +++ b/__tests__/components/projects/project-card.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; // Mock next/link to render a plain tag vi.mock("next/link", () => ({ @@ -17,6 +17,21 @@ vi.mock("next-auth/react", () => ({ useSession: () => ({ data: mockSessionAccessToken ? { accessToken: mockSessionAccessToken } : null }), })); +const favoriteMock = vi.fn(); +const unfavoriteMock = vi.fn(); + +vi.mock("@/lib/api/projects", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + projectApi: { + ...actual.projectApi, + favorite: (...args: unknown[]) => favoriteMock(...args), + unfavorite: (...args: unknown[]) => unfavoriteMock(...args), + }, + }; +}); + vi.mock("@/lib/stores/editorModeStore", () => ({ useEditorModeStore: (selector: (s: { preferEditMode: boolean }) => T) => selector({ preferEditMode: mockPreferEditMode }), @@ -43,6 +58,10 @@ describe("ProjectCard", () => { beforeEach(() => { mockSessionAccessToken = undefined; mockPreferEditMode = false; + favoriteMock.mockReset(); + unfavoriteMock.mockReset(); + favoriteMock.mockResolvedValue({}); + unfavoriteMock.mockResolvedValue({}); }); // ── Basic rendering ───────────────────────────────────────────── @@ -184,7 +203,92 @@ describe("ProjectCard", () => { // ── Custom className ──────────────────────────────────────────── it("merges custom className onto the card", () => { render(); - const link = screen.getByRole("link"); - expect(link.className).toContain("extra-class"); + expect(screen.getByTestId("project-card").className).toContain("extra-class"); + }); + + // ── Favorites ─────────────────────────────────────────────────── + describe("favorite star", () => { + it("is hidden when no access token is supplied", () => { + render(); + expect(screen.queryByRole("button", { name: /favorites/i })).toBeNull(); + }); + + it("is shown to authenticated users and reflects the unfavorited state", () => { + render(); + const star = screen.getByRole("button", { name: "Add to favorites" }); + expect(star.getAttribute("aria-pressed")).toBe("false"); + }); + + it("reflects the favorited state", () => { + render( + + ); + const star = screen.getByRole("button", { name: "Remove from favorites" }); + expect(star.getAttribute("aria-pressed")).toBe("true"); + }); + + it("is not nested inside the project link", () => { + render(); + const star = screen.getByRole("button", { name: "Add to favorites" }); + expect(star.closest("a")).toBeNull(); + }); + + it("optimistically notifies the parent before the request settles", async () => { + const onFavoriteChange = vi.fn(); + let resolveFavorite: (value: unknown) => void = () => {}; + favoriteMock.mockReturnValue( + new Promise((resolve) => { + resolveFavorite = resolve; + }) + ); + + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Add to favorites" })); + + expect(onFavoriteChange).toHaveBeenCalledWith("proj-123", true); + expect(favoriteMock).toHaveBeenCalledWith("proj-123", "tok"); + + await act(async () => { + resolveFavorite({}); + }); + expect(onFavoriteChange).toHaveBeenCalledTimes(1); + }); + + it("rolls the parent back when the request fails", async () => { + const onFavoriteChange = vi.fn(); + favoriteMock.mockRejectedValue(new Error("boom")); + + render( + + ); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Add to favorites" })); + }); + + expect(onFavoriteChange).toHaveBeenNthCalledWith(1, "proj-123", true); + expect(onFavoriteChange).toHaveBeenNthCalledWith(2, "proj-123", false); + }); + + it("calls unfavorite when the project is already favorited", async () => { + unfavoriteMock.mockResolvedValue({}); + render( + + ); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Remove from favorites" })); + }); + expect(unfavoriteMock).toHaveBeenCalledWith("proj-123", "tok"); + expect(favoriteMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/page.tsx b/app/page.tsx index a2817afb..afb52d9b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,6 +11,9 @@ import { ProjectCard } from "@/components/projects/project-card"; import { projectApi } from "@/lib/api/projects"; import { cn } from "@/lib/utils"; +/** Shared empty map so identity-mismatched renders keep a stable reference. */ +const EMPTY_FAVORITE_OVERRIDES: ReadonlyMap = new Map(); + const PAGE_SIZE = 50; type FilterType = "public" | "private" | "mine" | "all"; @@ -64,7 +67,46 @@ export default function HomePage() { enabled: status !== "loading" && !((filter === "mine" || filter === "private") && !isAuthenticated), }); - const projects = data?.pages.flatMap((page) => page.items) ?? []; + // Optimistic overrides: only tracks favorite toggles made in this session. + // Server data (is_favorited) remains the source of truth for everything else. + // + // Favorites are per-user, so the overrides are stamped with the identity that + // produced them and discarded during render when that identity changes. This + // keeps a sign-out/sign-in from applying one account's toggles to another's + // list, without a setState-in-effect. + const favoriteIdentity = session?.user?.id ?? session?.accessToken ?? null; + const [favoriteState, setFavoriteState] = useState<{ + identity: string | null; + overrides: ReadonlyMap; + }>({ identity: null, overrides: EMPTY_FAVORITE_OVERRIDES }); + + const favoriteOverrides = + favoriteState.identity === favoriteIdentity + ? favoriteState.overrides + : EMPTY_FAVORITE_OVERRIDES; + + const handleFavoriteChange = useCallback( + (projectId: string, isFavorited: boolean) => { + setFavoriteState((prev) => { + const base = + prev.identity === favoriteIdentity ? prev.overrides : EMPTY_FAVORITE_OVERRIDES; + return { + identity: favoriteIdentity, + overrides: new Map(base).set(projectId, isFavorited), + }; + }); + }, + [favoriteIdentity] + ); + + const rawProjects = data?.pages.flatMap((page) => page.items) ?? []; + // Array.prototype.sort is stable, so favorited projects float to the top while + // the server's created_at ordering is preserved within each group. + const projects = [...rawProjects].sort((a, b) => { + const aFav = favoriteOverrides.get(a.id) ?? a.is_favorited ?? false; + const bFav = favoriteOverrides.get(b.id) ?? b.is_favorited ?? false; + return (bFav ? 1 : 0) - (aFav ? 1 : 0); + }); const total = data?.pages.at(-1)?.total ?? 0; const unfilteredTotal = data?.pages.at(-1)?.unfiltered_total ?? 0; const isFiltered = (!!debouncedSearch || filter !== "all") && unfilteredTotal > total; @@ -245,7 +287,16 @@ export default function HomePage() {
{projects.map((project) => ( - + ))}
{hasNextPage && ( diff --git a/components/projects/project-card.tsx b/components/projects/project-card.tsx index 69b82033..5a1fca50 100644 --- a/components/projects/project-card.tsx +++ b/components/projects/project-card.tsx @@ -1,22 +1,27 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { useSession } from "next-auth/react"; -import { Globe, Lock, Users } from "lucide-react"; +import { Globe, Lock, Users, Star } from "lucide-react"; import { cn, formatDate } from "@/lib/utils"; -import type { Project } from "@/lib/api/projects"; +import { projectApi, type Project } from "@/lib/api/projects"; import { derivePermissions } from "@/lib/hooks/useProject"; import { useEditorModeStore } from "@/lib/stores/editorModeStore"; interface ProjectCardProps { project: Project; className?: string; + accessToken?: string; + onFavoriteChange?: (projectId: string, isFavorited: boolean) => void; } -export function ProjectCard({ project, className }: ProjectCardProps) { +export function ProjectCard({ project, className, accessToken, onFavoriteChange }: ProjectCardProps) { const { data: session } = useSession(); const { canSuggest } = derivePermissions(project, session?.accessToken); const preferEditMode = useEditorModeStore((s) => s.preferEditMode); + const isFavorited = project.is_favorited ?? false; + const [isToggling, setIsToggling] = useState(false); // When the user has prefer-edit-mode on AND has at least suggester rights, // open the project straight to the editor. Anyone without edit/suggest @@ -25,12 +30,36 @@ export function ProjectCard({ project, className }: ProjectCardProps) { ? `/projects/${project.id}/editor` : `/projects/${project.id}`; + const handleFavoriteClick = async () => { + if (!accessToken || isToggling) return; + + // Optimistic update via parent — parent holds the source of truth + onFavoriteChange?.(project.id, !isFavorited); + setIsToggling(true); + + try { + if (isFavorited) { + await projectApi.unfavorite(project.id, accessToken); + } else { + await projectApi.favorite(project.id, accessToken); + } + } catch { + onFavoriteChange?.(project.id, isFavorited); // rollback in parent + } finally { + setIsToggling(false); + } + }; + return ( - , and a
)} +
+ {project.is_public ? ( + + ) : ( + + )} +
@@ -103,6 +167,6 @@ export function ProjectCard({ project, className }: ProjectCardProps) { )} )} - + ); } diff --git a/lib/api/projects.ts b/lib/api/projects.ts index 21b7d543..13f41de2 100644 --- a/lib/api/projects.ts +++ b/lib/api/projects.ts @@ -51,6 +51,8 @@ export interface Project { is_exemplar?: boolean; exemplar_slug?: string; exemplar_source_url?: string; + // Favorites + is_favorited?: boolean; } export interface ProjectListResponse { @@ -300,6 +302,22 @@ export const projectApi = { headers: { Authorization: `Bearer ${token}` }, }), + /** + * Add a project to the current user's favorites + */ + favorite: (id: string, token: string) => + api.post(`/api/v1/projects/${id}/favorite`, {}, { + headers: { Authorization: `Bearer ${token}` }, + }), + + /** + * Remove a project from the current user's favorites + */ + unfavorite: (id: string, token: string) => + api.delete(`/api/v1/projects/${id}/favorite`, { + headers: { Authorization: `Bearer ${token}` }, + }), + /** * Transfer project ownership to an admin member * @param force - If true, proceed even if GitHub integration will be disconnected diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..a5df6637 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/johnrdorazio/development/CatholicOS_org/ontokit/ontokit-web/node_modules \ No newline at end of file