Merge pull request #8 from TheRealOwenRees/tests

Tests
This commit is contained in:
Owen Rees
2023-06-20 17:47:42 +02:00
committed by GitHub
29 changed files with 312 additions and 167 deletions
+13 -28
View File
@@ -1,33 +1,18 @@
//TODO readme
// TODO tests for tournament page:
- map and table mounts in tournament page <- get data to send to map/table
----------------------------------------------------------------
//TODO hover on table needs fixing - this only seemed to happen once I moved the logic into its own hook <- error is occurring on all hovers at the moment. Client/SSR issue?
//TODO font size on mobile screen
//TODO need a 'return to top' arrow button on smaller screens
//TODO mobile map needs improving
//TODO multi-language i18n support - https://nextjs.org/docs/app/building-your-application/routing/internationalization
//TODO tests for tournois, testing the loading of map and table, then counting the markers
//TODO logo for navbar and favicon
//TODO document my new hook <- consider removing as I believe this is the reason hover is broken
//TODO error handling
//TODO e2e tests for tournament page:
- make sure number of markers = tournamentData.length
//TODO SRP for web and API data fetching
//TODO about page
//TODO contact page needs working mailer
//TODO SEO
//TODO font size on mobile screen
//TODO SEO - next headers etc
//TODO data fetching tests
//TODO redo layer groups tests
----------------------------------------------------------------
//TODO logo for navbar and favicon
//TODO mobile map needs improving
//TODO multi-language i18n support - https://nextjs.org/docs/app/building-your-application/routing/internationalization
//TODO error handling
//TODO consider offering GraphQL support
//TODO move smaller ui components into a new folder, and make them reusable - such as using generic prop names
//TODO readme
@@ -3,8 +3,8 @@ import { dateOrderingFrance } from "@/utils/dbDateOrdering";
/**
* Tournament data API endpoint
* @route /api/tournaments/france
* @internal
* @route /api/v1/tournaments/france
* @public
*/
export const revalidate = 3600; // revalidate cache every 6 hours
export async function GET() {
@@ -15,7 +15,11 @@ export async function GET() {
const client = await clientPromise;
const db = client.db("tournamentsFranceDB");
const data = await dateOrderingFrance(db);
const results = await dateOrderingFrance(db);
const data = results.map(({ _id, __v, ...rest }) => ({
id: _id,
...rest,
}));
return new Response(JSON.stringify(data), { status: 200, headers });
} catch (error) {
+4 -1
View File
@@ -6,7 +6,10 @@ export default function Contact() {
<Layout>
<section className="grid place-items-center bg-white dark:bg-gray-800">
<div className="pt-8 pb-32 lg:pt-16 px-4 mx-auto max-w-screen-md">
<h2 className="mb-4 text-4xl tracking-tight font-extrabold text-center text-gray-900 dark:text-white">
<h2
className="mb-4 text-4xl tracking-tight font-extrabold text-center text-gray-900 dark:text-white"
data-cy="header2"
>
Contactez-Nous
</h2>
<p className="mb-8 lg:mb-16 font-light text-center text-gray-500 dark:text-gray-400 sm:text-xl">
+3 -1
View File
@@ -16,7 +16,9 @@ export default function Home() {
/>
</div>
<div className="absolute text-center text-white">
<h1 className="text-5xl p-5">Echecs France</h1>
<h1 className="text-5xl p-5" data-cy="header1">
Echecs France
</h1>
<h2 className="text-3xl p-5">
Trouvez Vos Tournois d&apos;Echecs en France Sur Une Carte
</h2>
+4 -1
View File
@@ -6,7 +6,10 @@ export default function About() {
<header className="grid h-[calc(100%-216px)] md:h-[calc(100%-174px)] place-items-center">
<div className="relative h-full w-full bg-white dark:bg-gray-800"></div>
<div className="absolute">
<h2 className="mb-4 text-4xl tracking-tight font-extrabold text-center text-gray-900 dark:text-white">
<h2
className="mb-4 text-4xl tracking-tight font-extrabold text-center text-gray-900 dark:text-white"
data-cy="header2"
>
Qui Sommes-Nous?
</h2>
<p className="mb-8 lg:mb-16 font-light text-center text-gray-500 dark:text-gray-400 sm:text-xl">
+2 -2
View File
@@ -37,11 +37,11 @@ export default async function Tournaments() {
return (
<Layout>
<main className="grid lg:grid-cols-2">
<main className="relative grid lg:grid-cols-2">
<div className="">
<TournamentMap tournamentData={JSON.parse(tournamentData)} />
</div>
<div className="bg-white dark:bg-gray-800 lg:overflow-y-auto">
<div className="relative bg-white dark:bg-gray-800 lg:overflow-y-auto">
<TournamentTable tournamentData={JSON.parse(tournamentData)} />
</div>
</main>
+4 -1
View File
@@ -3,7 +3,10 @@ import { FaGithub, FaRegEnvelope } from "react-icons/fa";
export default function Footer() {
return (
<footer className="grid w-full fixed bottom-0 justify-items-center py-2 px-5 text-white bg-teal-600 text-gray-900 dark:bg-gray-700">
<footer
className="grid w-full fixed bottom-0 justify-items-center py-2 px-5 text-white bg-teal-600 text-gray-900 dark:bg-gray-700 z-30"
data-cy="footer"
>
<div className="p-2">
<p>&copy; {new Date().getFullYear()} - Owen Rees</p>
</div>
+1
View File
@@ -11,6 +11,7 @@ const Hamburger = () => {
<div
ref={hamburgerButtonRef}
className="hamburger-button space-y-2 relative z-[99999]"
data-cy="hamburger-button"
onClick={() => setMenuVisible(!menuVisible)}
>
<div
+9 -3
View File
@@ -4,7 +4,10 @@ import Hamburger from "@/components/Hamburger";
export default function Navbar() {
return (
<nav className="w-full relative border-b-[1px] pt-5 mt-0 px-5 md:pt-2 bg-white dark:bg-gray-800 dark:border-gray-700 overflow-x-clip">
<nav
className="w-full relative border-b-[1px] pt-5 mt-0 px-5 md:pt-2 bg-white dark:bg-gray-800 dark:border-gray-700 overflow-x-clip"
data-cy="navbar"
>
<div className="container mx-auto flex items-center">
<div className="pb-3 justify-center flex w-full md:w-1/2 md:pb-0 md:justify-start font-extrabold">
<Link
@@ -14,11 +17,14 @@ export default function Navbar() {
<span className="text-2xl">Echecs France</span>
</Link>
</div>
<div className="mobile-menu pb-2 md:hidden">
<div className="pb-2 md:hidden" data-cy="mobile-menu">
<Hamburger />
</div>
<div className="desktop-menu hidden pt-2 justify-center md:flex md:w-1/2 md:justify-end">
<div
className="hidden pt-2 justify-center md:flex md:w-1/2 md:justify-end"
data-cy="desktop-menu"
>
<ul className="list-reset text-gray-900 dark:text-white no-underline flex flex-1 justify-around md:flex-none items-center">
<li className="mr-10">
<Link
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { FaArrowUp } from "react-icons/fa";
import { handleScrollToTop } from "@/handlers/scrollHandlers";
import { useEffect, useRef } from "react";
const ScrollToTopButton = ({ isLgScreen }: { isLgScreen: boolean }) => {
const scrollToTopElementRef = useRef<HTMLElement | null>(null);
// determine scrollable element based on screen size - window or div
useEffect(() => {
if (isLgScreen) {
scrollToTopElementRef.current =
document.getElementById("tournament-table");
}
}, [isLgScreen]);
const scrollToTopButtonClass = isLgScreen
? "absolute bottom-0 right-3 p-10"
: "fixed top-20 right-3";
return (
<button
className={`${scrollToTopButtonClass} z-10 text-2xl text-teal-900 dark:text-white`}
data-cy="scroll-to-top-button"
>
<FaArrowUp
onClick={() =>
handleScrollToTop(scrollToTopElementRef.current || window)
}
/>
</button>
);
};
export default ScrollToTopButton;
+27 -29
View File
@@ -10,37 +10,35 @@ const SearchBar = ({
setTournamentFilter,
}: SearchBarProps) => {
return (
<>
<div className="p-3 bg-white dark:bg-gray-800">
<label htmlFor="table-search" className="sr-only">
Search
</label>
<div className="relative mt-1">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
></path>
</svg>
</div>
<input
type="text"
id="table-search"
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-80 pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Search for items"
value={tournamentFilter}
onChange={(e) => setTournamentFilter(e.target.value)}
/>
<div className="p-3 bg-white dark:bg-gray-800">
<label htmlFor="table-search" className="sr-only">
Search
</label>
<div className="relative mt-1">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
></path>
</svg>
</div>
<input
type="text"
id="table-search"
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-80 pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Search for items"
value={tournamentFilter}
onChange={(e) => setTournamentFilter(e.target.value)}
/>
</div>
</>
</div>
);
};
+10 -2
View File
@@ -20,11 +20,19 @@ const ThemeSwitcher = () => {
return (
<div>
{colorTheme === "light" ? (
<div className="toggle-dark" onClick={() => setTheme("light")}>
<div
className="toggle-dark"
data-cy="toggle-dark"
onClick={() => setTheme("light")}
>
<MdBrightness2 className="theme-icon-dark" />
</div>
) : (
<div className="toggle" onClick={() => setTheme("dark")}>
<div
className="toggle"
data-cy="toggle"
onClick={() => setTheme("dark")}
>
<MdCircle className="theme-icon" />
</div>
)}
+90 -18
View File
@@ -2,39 +2,111 @@
import { TournamentDataProps } from "@/types";
import { useEffect, useState } from "react";
import useTournamentDataFilter from "@/hooks/useTournamentFilter";
import SearchBar from "@/components/SearchBar";
import ScrollToTopButton from "@/components/ScrollToTopButton";
export default function TournamentTable({
tournamentData,
}: TournamentDataProps) {
const [tournamentFilter, setTournamentFilter] = useState(""); // text from search bar
let tableData;
const [searchQuery, setSearchQuery] = useState(""); // text from search bar
const [filteredTournamentData, setFilteredTournamentData] =
useTournamentDataFilter(tournamentData);
useState(tournamentData);
const [isLgScreen, setIsLgScreen] = useState(false);
useEffect(() => {
setFilteredTournamentData(tournamentFilter);
}, [tournamentFilter]);
setFilteredTournamentData(
tournamentData.filter((t) => t.town.includes(searchQuery.toUpperCase()))
);
}, [searchQuery]);
const stickyHeader =
"sticky top-0 p-3 bg-teal-600 text-white dark:bg-gray-600";
useEffect(() => {
const handleResize = () => {
setIsLgScreen(window.innerWidth >= 1024);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
// TODO move this section into its own function
if (filteredTournamentData.length === 0) {
tableData = (
<tr className="bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
<td colSpan={4} className="p-3">
No tournaments found
</td>
</tr>
);
} else {
tableData = filteredTournamentData.map((t) => (
<tr
className="text-gray-900 bg-white dark:bg-gray-800 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-900"
key={t._id}
>
<td className="p-3">
<a href={t.url} target="_blank">
{t.date}
</a>
</td>
<td className="p-3">
<a href={t.url} target="_blank">
{t.town}
</a>
</td>
<td className="p-3">
<a href={t.url} target="_blank">
{t.tournament}
</a>
</td>
<td className="p-3">
<a href={t.url} target="_blank">
{t.time_control}
</a>
</td>
</tr>
));
}
return (
<section className="w-full grid auto-rows-max pb-20 lg:h-[calc(100vh-174px)] lg:col-start-2 lg:col-end-3">
<SearchBar
tournamentFilter={tournamentFilter}
setTournamentFilter={setTournamentFilter}
/>
<table className="relative table-fixed w-full text-center text-xs">
<section
className="tournament-table w-full grid auto-rows-max pb-20 lg:h-[calc(100vh-174px)] lg:col-start-2 lg:col-end-3 lg:overflow-y-scroll"
id="tournament-table"
data-cy="tournament-table-div"
>
<div className="flex z-10">
<SearchBar
tournamentFilter={searchQuery}
setTournamentFilter={setSearchQuery}
/>
<div>
<ScrollToTopButton isLgScreen={isLgScreen} />
</div>
</div>
<table
className="relative table-fixed w-full text-center text-xs"
data-cy="tournament-table"
>
<thead>
<tr>
<th className={stickyHeader}>Date</th>
<th className={stickyHeader}>Ville</th>
<th className={stickyHeader}>Tournois</th>
<th className={stickyHeader}>Cadence</th>
<th className="sticky top-0 p-3 bg-teal-600 text-white dark:bg-gray-600">
Date
</th>
<th className="sticky top-0 p-3 bg-teal-600 text-white dark:bg-gray-600">
Ville
</th>
<th className="sticky top-0 p-3 bg-teal-600 text-white dark:bg-gray-600">
Tournois
</th>
<th className="sticky top-0 p-3 bg-teal-600 text-white dark:bg-gray-600">
Cadence
</th>
</tr>
</thead>
<tbody>{filteredTournamentData}</tbody>
<tbody>{tableData}</tbody>
</table>
</section>
);
+11 -5
View File
@@ -4,11 +4,11 @@ import Contact from "@/app/contactez-nous/page";
const navbarFooterCheck = () => {
it("includes navbar", () => {
cy.get("nav");
cy.get("[data-cy='navbar']");
});
it("includes footer", () => {
cy.get("footer");
cy.get("[data-cy='footer']");
});
};
@@ -19,7 +19,9 @@ describe("Verify component mount", () => {
});
it("correct h1 tags with website name included", () => {
cy.get("h1").contains("echecs france", { matchCase: false });
cy.get("[data-cy='header1']").contains("echecs france", {
matchCase: false,
});
});
navbarFooterCheck();
@@ -31,7 +33,9 @@ describe("Verify component mount", () => {
});
it("correct h1 tags with page title included", () => {
cy.get("h2").contains("qui sommes-nous", { matchCase: false });
cy.get("[data-cy='header2']").contains("qui sommes-nous", {
matchCase: false,
});
});
navbarFooterCheck();
@@ -43,7 +47,9 @@ describe("Verify component mount", () => {
});
it("correct h1 tags with page title included", () => {
cy.get("h2").contains("contactez-nous", { matchCase: false });
cy.get("[data-cy='header2']").contains("contactez-nous", {
matchCase: false,
});
});
navbarFooterCheck();
+15
View File
@@ -0,0 +1,15 @@
import ScrollToTopButton from "@/components/ScrollToTopButton";
describe("Scroll to top button", () => {
it("desktop scroll button clickable", () => {
cy.viewport("macbook-15");
cy.mount(<ScrollToTopButton isLgScreen={true} />);
cy.get("[data-cy='scroll-to-top-button']").should("exist").click();
});
it("mobile scroll button clickable", () => {
cy.viewport(600, 600);
cy.mount(<ScrollToTopButton isLgScreen={false} />);
cy.get("[data-cy='scroll-to-top-button']").should("exist").click();
});
});
+5 -8
View File
@@ -1,26 +1,23 @@
import React from "react";
import ThemeSwitcher from "../../components/ThemeSwitcher";
import Home from "@/app/page";
import "@/css/theme-toggle.css";
describe("ThemeSwitcher component", () => {
it("should toggle between light and dark mode", () => {
cy.mount(<Home />);
cy.mount(<ThemeSwitcher />);
cy.wait(100);
// checking that the toggle is there and light mode is active
cy.get(".toggle").should("exist");
cy.get(".toggle").should(($toggle) => {
cy.get("[data-cy='toggle']").should("exist");
cy.get("[data-cy='toggle']").should(($toggle) => {
const backgroundImage = $toggle.css("background-image");
expect(backgroundImage).to.include(
"linear-gradient(rgb(0, 255, 255), rgb(135, 206, 235)"
);
});
// checking that the toggle is clickable and dark mode is active
cy.get(".toggle").click();
cy.get(".toggle-dark").should("exist");
cy.get(".toggle-dark").should(($toggle) => {
cy.get("[data-cy='toggle']").click();
cy.get("[data-cy='toggle-dark']").should("exist");
cy.get("[data-cy='toggle-dark']").should(($toggle) => {
const backgroundImage = $toggle.css("background-image");
expect(backgroundImage).to.include(
"linear-gradient(rgb(25, 25, 112), rgb(102, 51, 153)"
+27
View File
@@ -0,0 +1,27 @@
let tableRows: number;
describe("Data fetching for map", () => {
it("map markers is equal to table rows", () => {
cy.visit("/tournois");
cy.get('[data-cy="tournament-table"]')
.find("tr")
.then((rows) => {
tableRows = rows.length - 1;
});
cy.get(".leaflet-marker-icon").then((markers) => {
expect(markers.length).to.eq(tableRows);
});
});
});
describe("Data fetching from API endpoints", () => {
it("api call should equal website data count", () => {
cy.request("GET", "http://localhost:3000/api/v1/tournaments/france").then(
(response) => {
expect(response.status).to.eq(200);
const responseData = response.body;
const itemCount = responseData.length;
expect(itemCount).to.eq(tableRows);
}
);
});
});
+1 -1
View File
@@ -9,7 +9,7 @@ describe("Test all links", () => {
cy.viewport(600, 600);
cy.visit("/");
pages.forEach((page) => {
cy.get(".hamburger-button").click();
cy.get("[data-cy='hamburger-button']").click();
cy.contains(page, { matchCase: false }).click();
cy.location("pathname").should("eq", `/${navLinkToSlug(page)}`); // url path matches link name, replacing whitespace with hyphens
cy.go("back");
+6 -6
View File
@@ -5,12 +5,12 @@ describe("Mobile Navbar", () => {
});
it("hamburger menu on mobile screens", () => {
cy.get(".mobile-menu").should("be.not.hidden");
cy.get(".desktop-menu").should("be.hidden");
cy.get("[data-cy='mobile-menu']").should("be.visible");
cy.get("[data-cy='desktop-menu']").should("be.not.visible");
});
it("hamburger button available", () => {
cy.get(".hamburger-button").should("be.visible");
cy.get("[data-cy='hamburger-button']").should("be.visible");
});
});
@@ -21,11 +21,11 @@ describe("Desktop Navbar", () => {
});
it("desktop menu on larger screens", () => {
cy.get(".mobile-menu").should("be.hidden");
cy.get(".desktop-menu").should("be.not.hidden");
cy.get("[data-cy='mobile-menu']").should("be.hidden");
cy.get("[data-cy='desktop-menu']").should("be.not.hidden");
});
it("hamburger button hidden", () => {
cy.get(".hamburger-button").should("be.not.visible");
cy.get("[data-cy='hamburger-button']").should("be.not.visible");
});
});
+26
View File
@@ -0,0 +1,26 @@
describe("Scroll to top button", () => {
beforeEach(() => {
cy.visit("/tournois");
});
it("desktop scroll to top", () => {
cy.viewport(1024, 800);
cy.get("[data-cy='tournament-table-div']").scrollTo("bottom");
cy.get("[data-cy='scroll-to-top-button']").click();
// cy.get("[data-cy='tournament-table-div']")
// .invoke("scrollTop")
// .should("equal", 0);
// cy.get("[data-cy='tournament-table-div']")
// .its("scrollY")
// .should("not.equal", 0);
// TODO need to test element scrolls back to 0
});
it("mobile scroll to top", () => {
cy.viewport(600, 600);
cy.scrollTo("bottom");
cy.window().its("scrollY").should("not.equal", 0);
cy.get("[data-cy='scroll-to-top-button']").click();
cy.window().its("scrollY").should("equal", 0);
});
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
import { ScrollableElement } from "@/types";
const isBrowser = () => typeof window !== "undefined";
export const handleScrollToTop = (element: ScrollableElement | null) => {
if (!isBrowser()) return;
element?.scrollTo({ top: 0, behavior: "smooth" });
};
-57
View File
@@ -1,57 +0,0 @@
import { Tournament } from "@/types";
import { useState } from "react";
// TODO change 'any'
// TODO document this hook with TSDoc
const useTournamentDataFilter = (
initialState: Tournament[]
): (any | ((searchQuery: string) => void))[] => {
const [filtered, setFiltered] = useState(initialState);
let state;
const setState = (searchQuery: string) => {
setFiltered(
initialState.filter((t) => t.town.includes(searchQuery.toUpperCase()))
);
};
if (filtered.length === 0) {
state = (
<tr className="bg-white text-gray-900 dark:bg-gray-800 dark:text-white">
<td colSpan={4} className="p-3">
No tournaments found
</td>
</tr>
);
} else {
state = filtered.map((t) => (
<tr
className="text-gray-900 bg-white dark:bg-gray-800 dark:text-white hover:bg-gray-400 hover:text-black"
key={t._id}
>
<td className="p-3">
<a href={t.url} target="_blank">
{t.date}
</a>
</td>
<td className="p-3">
<a href={t.url} target="_blank">
{t.town}
</a>
</td>
<td className="p-3">
<a href={t.url} target="_blank">
{t.tournament}
</a>
</td>
<td className="p-3">
<a href={t.url} target="_blank">
{t.time_control}
</a>
</td>
</tr>
));
}
return [state, setState];
};
export default useTournamentDataFilter;
@@ -0,0 +1,2 @@
GET http://localhost:3000/api/v1/tournaments/france
Accept: application/json
+2
View File
@@ -12,3 +12,5 @@ export interface Tournament {
export interface TournamentDataProps {
tournamentData: Tournament[];
}
export type ScrollableElement = Window | HTMLElement;
-1
View File
@@ -3,7 +3,6 @@ import { Db } from "mongodb";
/**
* Converts date from string into a date to allow ordering
*/
// TODO remove the .toArray method as this is not SRP friendly
export const dateOrderingFrance = async (db: Db) => {
return await db
.collection("tournaments")