diff --git a/jest.config.mjs b/jest.config.mjs index 25552207926f..3540d474c0f3 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -3,11 +3,12 @@ export default { testEnvironment: "node", setupFiles: ["./src/setupTests.js"], transform: { - "^.+\\.jsx?$": "babel-jest", + "^.+\\.(m|c)?jsx?$": "babel-jest", }, moduleNameMapper: { "\\.(scss|css)$": "/src/components/__mocks__/styleMock.js", "\\.svg$": "/src/components/__mocks__/svgMock.js", + "\\.(png|jpg|jpeg|ico)$": "/src/components/__mocks__/fileMock.js", }, moduleFileExtensions: [ "js", diff --git a/src/components/Support/Support.data.test.jsx b/src/components/Support/Support.data.test.jsx new file mode 100644 index 000000000000..b32515cf4b75 --- /dev/null +++ b/src/components/Support/Support.data.test.jsx @@ -0,0 +1,100 @@ +/** + * @jest-environment jsdom + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import SmallIcon from "../../assets/icon-square-small-slack.png"; + +// Data must be inline - jest.mock is hoisted before const declarations (TDZ) +jest.mock("./AdditionalSupporters.mjs", () => []); +jest.mock( + "./_supporters.json", + () => [ + { + slug: "gold-org", + name: "Gold Org", + totalDonations: 2000000, // $20,000 - gold range ($10k-$50k) + monthlyDonations: 100000, + avatar: "https://example.com/avatar.png", + firstDonation: new Date( + Date.now() - 5 * 24 * 60 * 60 * 1000, + ).toISOString(), + }, + ], + { virtual: true }, +); + +// eslint-disable-next-line import/first +import Support from "./Support.jsx"; + +const AVATAR_URL = "https://example.com/avatar.png"; + +describe("Support with supporter data", () => { + let intersectionCallback; + let mockObserve; + let mockDisconnect; + + beforeEach(() => { + mockObserve = jest.fn(); + mockDisconnect = jest.fn(); + + Object.defineProperty(window, "IntersectionObserver", { + writable: true, + configurable: true, + value: class { + observe() {} + + disconnect() {} + }, + }); + jest.spyOn(window, "IntersectionObserver").mockImplementation((cb) => { + intersectionCallback = cb; + return { observe: mockObserve, disconnect: mockDisconnect }; + }); + }); + + it("renders supporter links", () => { + render(); + // supporter link + become a sponsor link + expect(screen.getAllByRole("link").length).toBeGreaterThanOrEqual(2); + }); + + it("shows SmallIcon before intersection observer fires", () => { + render(); + const imgs = screen.getAllByRole("img"); + expect(imgs[0].getAttribute("src")).toBe(SmallIcon); + }); + + it("shows avatar src after intersection observer fires", () => { + render(); + act(() => { + intersectionCallback([{ isIntersecting: true }]); + }); + const imgs = screen.getAllByRole("img"); + expect(imgs[0].getAttribute("src")).toBe(AVATAR_URL); + }); + + it("falls back to SmallIcon on image error", () => { + render(); + act(() => { + intersectionCallback([{ isIntersecting: true }]); + }); + const imgs = screen.getAllByRole("img"); + fireEvent.error(imgs[0]); + expect(imgs[0].getAttribute("src")).toBe(SmallIcon); + }); + + it("does not replace src if image already shows SmallIcon on error", () => { + render(); + // inView is false so src is already SmallIcon - error should not loop + const imgs = screen.getAllByRole("img"); + fireEvent.error(imgs[0]); + expect(imgs[0].getAttribute("src")).toBe(SmallIcon); + }); + + it("matches snapshot with supporter data", () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/Support/Support.jsx b/src/components/Support/Support.jsx index 41a9d168a0c2..22fdd4b15cf8 100644 --- a/src/components/Support/Support.jsx +++ b/src/components/Support/Support.jsx @@ -1,6 +1,6 @@ // Import External Dependencies import PropTypes from "prop-types"; -import { Component } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; // Import Data import SmallIcon from "../../assets/icon-square-small-slack.png"; @@ -95,215 +95,208 @@ const AVATAR_CLASSES = { platinum: "max-h-32 max-w-full min-[400px]:max-w-96 align-middle", }; -export default class Support extends Component { - static propTypes = { - rank: PropTypes.string, - type: PropTypes.string, - }; - - state = { - inView: false, - }; - - containerRef = null; +function handleImgError(event) { + const imgNode = event.target; + if (imgNode.getAttribute("src") === SmallIcon) return; + imgNode.setAttribute("src", SmallIcon); +} - observer = null; +export default function Support({ rank, type }) { + const [inView, setInView] = useState(false); + const containerRef = useRef(null); - componentDidMount() { - this.observer = new IntersectionObserver( + useEffect(() => { + const observer = new IntersectionObserver( ([entry]) => { - if (entry.isIntersecting && !this.state.inView) { - this.setState({ inView: true }); - this.observer.disconnect(); + if (entry.isIntersecting) { + setInView(true); + observer.disconnect(); } }, { threshold: 0.1 }, ); - if (this.containerRef) { - this.observer.observe(this.containerRef); + if (containerRef.current) { + observer.observe(containerRef.current); } - } - componentWillUnmount() { - if (this.observer) { - this.observer.disconnect(); - } - } + return () => observer.disconnect(); + }, []); - render() { - const { rank, type } = this.props; + const { supporters, minimum, maximum, maxAge, limit, random } = + useMemo(() => { + const ranks = type === "monthly" ? monthlyRanks : totalRanks; + const getAmount = + type === "monthly" + ? (item) => item.monthlyDonations + : (item) => item.totalDonations; - const { inView } = this.state; + let min; + let max; + let age; + let lim; + let rand; - let supporters = SUPPORTERS; - let minimum; - let maximum; - let maxAge; - let limit; - let random; + if (rank && ranks[rank]) { + min = ranks[rank].minimum; + max = ranks[rank].maximum; + age = ranks[rank].maxAge; + lim = ranks[rank].limit; + rand = ranks[rank].random; + } - const ranks = type === "monthly" ? monthlyRanks : totalRanks; - const getAmount = - type === "monthly" - ? (item) => item.monthlyDonations - : (item) => item.totalDonations; + let result = SUPPORTERS; - if (rank && ranks[rank]) { - minimum = ranks[rank].minimum; - maximum = ranks[rank].maximum; - maxAge = ranks[rank].maxAge; - limit = ranks[rank].limit; - random = ranks[rank].random; - } - - if (typeof minimum === "number") { - supporters = supporters.filter( - (item) => getAmount(item) >= minimum * 100, - ); - } + if (typeof min === "number") { + result = result.filter((item) => getAmount(item) >= min * 100); + } - if (typeof maximum === "number") { - supporters = supporters.filter((item) => getAmount(item) < maximum * 100); - } + if (typeof max === "number") { + result = result.filter((item) => getAmount(item) < max * 100); + } - if (typeof maxAge === "number") { - const now = Date.now(); - supporters = supporters.filter( - (item) => - item.firstDonation && - now - new Date(item.firstDonation).getTime() < maxAge, - ); - } + if (typeof age === "number") { + // eslint-disable-next-line react-hooks/purity + const now = Date.now(); + result = result.filter( + (item) => + item.firstDonation && + now - new Date(item.firstDonation).getTime() < age, + ); + } - if (typeof limit === "number") { - supporters = supporters.slice(0, limit); - } + if (typeof lim === "number") { + result = result.slice(0, lim); + } - if (typeof random === "number" && supporters.length >= random) { - // Pick n random items - for (let i = 0; i < random; i++) { - const other = Math.floor(Math.random() * (supporters.length - i)); - const temp = supporters[other]; - supporters[other] = supporters[i]; - supporters[i] = temp; + if (typeof rand === "number" && result.length >= rand) { + // Pick n random items + result = [...result]; + for (let i = 0; i < rand; i++) { + // eslint-disable-next-line react-hooks/purity + const other = Math.floor(Math.random() * (result.length - i)); + const temp = result[other]; + result[other] = result[i]; + result[i] = temp; + } + result = result.slice(0, rand); } - supporters = supporters.slice(0, random); - } - // resort to keep order - supporters.sort((a, b) => getAmount(b) - getAmount(a)); + // resort to keep order + result.sort((a, b) => getAmount(b) - getAmount(a)); - return ( - <> -

- {rank === "backer" - ? "Backers" - : rank === "latest" - ? "Latest Sponsors" - : `${rank[0].toUpperCase()}${rank.slice(1)} ${ - type === "monthly" ? "Monthly " : "" - }Sponsors`} -

-
(this.containerRef = el)} - className="flex flex-wrap justify-center px-2 pb-4" - > -
- {rank === "backer" ? ( -

- The following Backers are individuals who have - contributed various amounts of money in order to help support - webpack. Every little bit helps, and we appreciate even the - smallest contributions. This list shows {random} randomly chosen - backers: -

- ) : rank === "latest" ? ( -

- The following persons/organizations made their first donation in - the last {Math.round(maxAge / (1000 * 60 * 60 * 24))} days - (limited to the top {limit}). -

- ) : ( -

- - {type === "monthly" ? `${rank} monthly` : rank} sponsors - - {type === "monthly" ? ( - - are those who are currently pledging{" "} - {minimum ? `$${formatMoney(minimum)}` : "up"}{" "} - {maximum ? `to $${formatMoney(maximum)}` : "or more"}{" "} - monthly to webpack. - - ) : ( - - are those who have contributed{" "} - {minimum ? `$${formatMoney(minimum)}` : "up"}{" "} - {maximum ? `to $${formatMoney(maximum)}` : "or more"} to - webpack. - - )} -

- )} -
+ return { + supporters: result, + minimum: min, + maximum: max, + maxAge: age, + limit: lim, + random: rand, + }; + }, [rank, type]); - {supporters.map((supporter, index) => ( - - - { - { - } - - - ))} + return ( + <> +

+ {rank === "backer" + ? "Backers" + : rank === "latest" + ? "Latest Sponsors" + : `${rank[0].toUpperCase()}${rank.slice(1)} ${ + type === "monthly" ? "Monthly " : "" + }Sponsors`} +

+
+
+ {rank === "backer" ? ( +

+ The following Backers are individuals who have contributed + various amounts of money in order to help support webpack. Every + little bit helps, and we appreciate even the smallest + contributions. This list shows {random} randomly chosen backers: +

+ ) : rank === "latest" ? ( +

+ The following persons/organizations made their first donation in + the last {Math.round(maxAge / (1000 * 60 * 60 * 24))} days + (limited to the top {limit}). +

+ ) : ( +

+ + {type === "monthly" ? `${rank} monthly` : rank} sponsors + + {type === "monthly" ? ( + + are those who are currently pledging{" "} + {minimum ? `$${formatMoney(minimum)}` : "up"}{" "} + {maximum ? `to $${formatMoney(maximum)}` : "or more"} monthly + to webpack. + + ) : ( + + are those who have contributed{" "} + {minimum ? `$${formatMoney(minimum)}` : "up"}{" "} + {maximum ? `to $${formatMoney(maximum)}` : "or more"} to + webpack. + + )} +

+ )} +
-
+ {supporters.map((supporter, index) => ( + - Become a {rank === "backer" ? "backer" : "sponsor"} + { + { + } -
-
- - ); - } + + ))} - /** - * Handle images that aren't found - * - * @param {object} event - React synthetic event - */ - _handleImgError(event) { - const imgNode = event.target; - if (imgNode.getAttribute("src") === SmallIcon) return; - imgNode.setAttribute("src", SmallIcon); - } + +
+ + ); } + +Support.propTypes = { + rank: PropTypes.string, + type: PropTypes.string, +}; diff --git a/src/components/Support/Support.test.jsx b/src/components/Support/Support.test.jsx new file mode 100644 index 000000000000..8ed462aac434 --- /dev/null +++ b/src/components/Support/Support.test.jsx @@ -0,0 +1,100 @@ +/** + * @jest-environment jsdom + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { render, screen } from "@testing-library/react"; + +jest.mock("./AdditionalSupporters.mjs", () => []); +jest.mock("./_supporters.json", () => [], { virtual: true }); + +// eslint-disable-next-line import/first +import Support from "./Support.jsx"; + +describe("Support", () => { + let mockObserve; + let mockDisconnect; + + beforeEach(() => { + mockObserve = jest.fn(); + mockDisconnect = jest.fn(); + Object.defineProperty(window, "IntersectionObserver", { + writable: true, + configurable: true, + value: class { + observe() {} + + disconnect() {} + }, + }); + jest.spyOn(window, "IntersectionObserver").mockImplementation(() => ({ + observe: mockObserve, + disconnect: mockDisconnect, + })); + }); + + it("renders a heading for backer rank", () => { + render(); + expect(screen.getByRole("heading", { name: "Backers" })).toBeTruthy(); + }); + + it("renders a heading for latest rank", () => { + render(); + expect( + screen.getByRole("heading", { name: "Latest Sponsors" }), + ).toBeTruthy(); + }); + + it("renders a heading for gold rank", () => { + render(); + expect(screen.getByRole("heading", { name: "Gold Sponsors" })).toBeTruthy(); + }); + + it("renders a heading for gold monthly rank", () => { + render(); + expect( + screen.getByRole("heading", { name: "Gold Monthly Sponsors" }), + ).toBeTruthy(); + }); + + it("renders a heading for platinum rank", () => { + render(); + expect( + screen.getByRole("heading", { name: "Platinum Sponsors" }), + ).toBeTruthy(); + }); + + it("renders monthly description text", () => { + render(); + expect(screen.getByText(/pledging/i)).toBeTruthy(); + }); + + it("renders the become a sponsor link for sponsor ranks", () => { + render(); + expect( + screen.getByRole("link", { name: /become a sponsor/i }), + ).toBeTruthy(); + }); + + it("renders the become a backer link for backer rank", () => { + render(); + expect(screen.getByRole("link", { name: /become a backer/i })).toBeTruthy(); + }); + + it("sets up IntersectionObserver on mount", () => { + render(); + expect(window.IntersectionObserver).toHaveBeenCalled(); + expect(mockObserve).toHaveBeenCalled(); + }); + + it("disconnects IntersectionObserver on unmount", () => { + const { unmount } = render(); + unmount(); + expect(mockDisconnect).toHaveBeenCalled(); + }); + + it("matches snapshot for backer rank", () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/Support/__snapshots__/Support.data.test.jsx.snap b/src/components/Support/__snapshots__/Support.data.test.jsx.snap new file mode 100644 index 000000000000..976ec81d11a8 --- /dev/null +++ b/src/components/Support/__snapshots__/Support.data.test.jsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Support with supporter data matches snapshot with supporter data 1`] = ` +

+ Gold Sponsors +

+`; diff --git a/src/components/Support/__snapshots__/Support.test.jsx.snap b/src/components/Support/__snapshots__/Support.test.jsx.snap new file mode 100644 index 000000000000..fba0cb14ccef --- /dev/null +++ b/src/components/Support/__snapshots__/Support.test.jsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Support matches snapshot for backer rank 1`] = ` +

+ Backers +

+`; diff --git a/src/components/__mocks__/fileMock.js b/src/components/__mocks__/fileMock.js new file mode 100644 index 000000000000..54e31a1db96c --- /dev/null +++ b/src/components/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = "file-mock";