Skip to content

Commit e9ab1c2

Browse files
Cfla446Cormac
andauthored
feat: add favouriting rooms functionality (#808)
* fix: fix merge conflict issues * feat: add useBookmarks.ts hook file and functions * fix: pass new bookmarks value to StorageEvent and convert ServerBookmarkSnapshot to use stable primitive value * fix: bookmarks resolve sync loop issue and persist bookmark state via. snapshot caching correctly * style: linting * style: clean up bookmarks code * style: move bookmark button to right side of UI next to booking button and add aria-label for accessibility * style: linting * style: alter bookmark button to be heart rather than save icon * fix: add back 'View on Map' button to room page * feat: rewrite useBookmarks hook to reflect style in clientLayout (StorageEvent/useSyncExternalStore) * fix: cache parsed bookmarks array to prevent duplicate array contents with new array reference * style: linting * test: add BookmarkButton test cases * refactor: rename Bookmarks functionality to Favourites --------- Co-authored-by: Cormac <cormac@LAPTOP-0C8K460R.localdomain>
1 parent 3ff84ac commit e9ab1c2

3 files changed

Lines changed: 223 additions & 9 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import "@testing-library/jest-dom";
2+
3+
import store from "@frontend/redux/store";
4+
import { fireEvent, render, screen } from "@testing-library/react";
5+
import { useParams, useRouter } from "next/navigation";
6+
import { Provider } from "react-redux";
7+
8+
import Page from "../app/room/[room]/page";
9+
10+
jest.mock("next/navigation", () => ({
11+
useParams: jest.fn(),
12+
useRouter: jest.fn(),
13+
}));
14+
15+
jest.mock("@mui/material", () => ({
16+
...jest.requireActual("@mui/material"),
17+
useMediaQuery: jest.fn().mockReturnValue(false),
18+
}));
19+
20+
jest.mock("../hooks/useBuilding", () => ({
21+
__esModule: true,
22+
default: (buildingId: string) => {
23+
return {
24+
building: { id: buildingId, name: "Ainsworth" },
25+
error: null,
26+
};
27+
},
28+
}));
29+
30+
jest.mock("../hooks/useRoom", () => ({
31+
__esModule: true,
32+
default: (roomId: string) => {
33+
return {
34+
room: {
35+
name: "Ainsworth 101",
36+
id: "K-J17-101",
37+
abbr: "Ainswth101",
38+
capacity: 50,
39+
usage: "TUSM",
40+
school: " ",
41+
},
42+
error: null,
43+
};
44+
},
45+
}));
46+
47+
describe("Favourite button", () => {
48+
beforeEach(() => {
49+
window.localStorage.clear();
50+
51+
(useParams as jest.Mock).mockReturnValue({ room: "K-J17-101" });
52+
(useRouter as jest.Mock).mockReturnValue({ back: jest.fn() });
53+
});
54+
55+
const renderRoomPage = () => {
56+
return render(
57+
<Provider store={store}>
58+
<Page />
59+
</Provider>
60+
);
61+
};
62+
63+
test("shows add favourite button when the room is not favourited", () => {
64+
renderRoomPage();
65+
66+
const favouriteIcon = screen.getByRole("button", {
67+
name: /Add as favourite/i,
68+
});
69+
expect(favouriteIcon).toBeInTheDocument();
70+
});
71+
72+
test("shows remove favourite button when the room is not favourited", () => {
73+
renderRoomPage();
74+
75+
fireEvent.click(screen.getByRole("button", { name: /Add as favourite/i })); // Trigger button click event.
76+
77+
const removeFavouriteIcon = screen.getByRole("button", {
78+
name: /Remove as favourite/i,
79+
});
80+
expect(removeFavouriteIcon).toBeInTheDocument();
81+
});
82+
83+
test("adds the room to localStorage when clicked", () => {
84+
renderRoomPage();
85+
86+
fireEvent.click(screen.getByRole("button", { name: /Add as favourite/i }));
87+
88+
const storedFavourites = JSON.parse(
89+
window.localStorage.getItem("favourite") ?? "[]"
90+
);
91+
expect(storedFavourites).toContain("K-J17-101");
92+
93+
const removefavouriteIcon = screen.getByRole("button", {
94+
name: /Remove as favourite/i,
95+
});
96+
expect(removefavouriteIcon).toBeInTheDocument();
97+
});
98+
99+
test("removes the room from localStorage when clicked again", () => {
100+
window.localStorage.setItem("favourite", JSON.stringify(["K-J17-101"]));
101+
102+
renderRoomPage();
103+
104+
fireEvent.click(
105+
screen.getByRole("button", { name: /Remove as favourite/i })
106+
);
107+
108+
const storedFavourites = JSON.parse(
109+
window.localStorage.getItem("favourite") ?? "[]"
110+
);
111+
expect(storedFavourites).not.toContain("K-J17-101");
112+
113+
const favouriteIcon = screen.getByRole("button", {
114+
name: /Add as favourite/i,
115+
});
116+
expect(favouriteIcon).toBeInTheDocument();
117+
});
118+
});

frontend/app/room/[room]/page.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import translateRoomUsage from "@common/roomUsages";
44
import getSchoolDetails from "@common/schools";
55
import type { Booking, Room } from "@common/types";
66
import CloseIcon from "@mui/icons-material/Close";
7+
import FavouriteIcon from "@mui/icons-material/Favorite";
8+
import FavouriteBorderIcon from "@mui/icons-material/FavoriteBorder";
79
import {
810
Dialog,
911
DialogContent,
@@ -32,6 +34,7 @@ import RoomBackButton from "../../../components/RoomBackButton";
3234
import ViewOnMapButton from "../../../components/ViewOnMapButton";
3335
import useBookings from "../../../hooks/useBookings";
3436
import useBuilding from "../../../hooks/useBuilding";
37+
import useFavourites from "../../../hooks/useFavourites";
3538
import useRoom from "../../../hooks/useRoom";
3639
import room_photos from "../../../public/room-photos.json";
3740
import { getBuildingIdFromRoomId } from "../../../utils/utils";
@@ -65,6 +68,7 @@ export default function Page() {
6568
const { room } = useRoom(roomParam);
6669
const [campus, grid] = room ? room.id.split("-") : ["", ""];
6770
const { building } = useBuilding(`${campus}-${grid}`);
71+
const { isFavourite, toggleFavourite } = useFavourites();
6872

6973
return (
7074
<Container maxWidth="xl">
@@ -82,7 +86,12 @@ export default function Page() {
8286
paddingRight: { xs: 3, md: 15 },
8387
}}
8488
>
85-
<RoomPageHeader room={room} buildingName={building.name} />
89+
<RoomPageHeader
90+
room={room}
91+
buildingName={building.name}
92+
favourite={isFavourite(room.id)}
93+
onToggleFavourite={() => toggleFavourite(room.id)}
94+
/>
8695
<RoomImage
8796
src={
8897
roomParam in room_photos
@@ -101,10 +110,12 @@ export default function Page() {
101110
);
102111
}
103112

104-
const RoomPageHeader: React.FC<{ room: Room; buildingName: string }> = ({
105-
room,
106-
buildingName,
107-
}) => {
113+
const RoomPageHeader: React.FC<{
114+
room: Room;
115+
buildingName: string;
116+
favourite: boolean;
117+
onToggleFavourite: () => void;
118+
}> = ({ room, buildingName, favourite, onToggleFavourite }) => {
108119
const [openDialog, setDialog] = useState(false);
109120

110121
const toggleDialog = () => {
@@ -183,11 +194,23 @@ const RoomPageHeader: React.FC<{ room: Room; buildingName: string }> = ({
183194
<Typography variant="h4" sx={{ fontWeight: 550 }}>
184195
{room.name}
185196
</Typography>
186-
<Stack
187-
direction={{ xs: "column", sm: "row" }}
188-
sx={{ justifyContent: "space-between" }}
189-
>
197+
198+
<Stack direction="row" spacing={1} align-items="center">
199+
<IconButton
200+
onClick={onToggleFavourite}
201+
aria-label={
202+
favourite ? "Remove as favourite" : "Add as favourite"
203+
}
204+
>
205+
{favourite ? (
206+
<FavouriteIcon color="primary" />
207+
) : (
208+
<FavouriteBorderIcon />
209+
)}
210+
</IconButton>
211+
190212
<ViewOnMapButton buildingId={buildingId} />
213+
191214
<BookingButton
192215
school={room.school}
193216
usage={room.usage}

frontend/hooks/useFavourites.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client";
2+
3+
import { useCallback, useSyncExternalStore } from "react";
4+
5+
// Caches last parsed favourites to avoid re-parsing identical arrays (which would create a duplicate array with different reference).
6+
// JS compares by reference rather than contents of array.
7+
let lastStoredFavourites: string | null = null;
8+
let lastParsedFavourites: string[] = [];
9+
10+
const subscribeToFavourites = (callback: () => void) => {
11+
const handleStorage = (event: StorageEvent) => {
12+
if (event.key === "favourite") {
13+
callback();
14+
}
15+
};
16+
17+
window.addEventListener("storage", handleStorage);
18+
return () => {
19+
window.removeEventListener("storage", handleStorage);
20+
};
21+
};
22+
23+
const getClientFavouriteSnapshot = (): string[] => {
24+
const storedFavourites = window.localStorage.getItem("favourite");
25+
26+
// Ensuring JSON.parse does not create a new array each call.
27+
if (storedFavourites === lastStoredFavourites) {
28+
return lastParsedFavourites;
29+
}
30+
31+
lastStoredFavourites = storedFavourites;
32+
lastParsedFavourites = storedFavourites ? JSON.parse(storedFavourites) : [];
33+
34+
return lastParsedFavourites;
35+
};
36+
37+
const getServerFavouriteSnapshot = (): string[] => [];
38+
39+
export default function useFavourites() {
40+
const favourites = useSyncExternalStore(
41+
subscribeToFavourites,
42+
getClientFavouriteSnapshot,
43+
getServerFavouriteSnapshot
44+
);
45+
46+
const isFavourite = (roomId: string) => {
47+
return favourites.includes(roomId);
48+
};
49+
50+
const toggleFavourite = useCallback(
51+
(roomId: string) => {
52+
const nextFavourites = favourites.includes(roomId)
53+
? favourites.filter((id) => id !== roomId)
54+
: [...favourites, roomId];
55+
const nextValue = JSON.stringify(nextFavourites);
56+
57+
window.localStorage.setItem("favourite", nextValue);
58+
window.dispatchEvent(
59+
new StorageEvent("storage", {
60+
key: "favourite",
61+
newValue: nextValue,
62+
})
63+
);
64+
},
65+
[favourites]
66+
);
67+
68+
return {
69+
favourites,
70+
isFavourite,
71+
toggleFavourite,
72+
};
73+
}

0 commit comments

Comments
 (0)