diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c416bbc..4df449ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Change format filters to "Readable", "Downloadable", and "Requestable" - Upgrade to Web Reader version 3.1.0 - Adds author and work title to edition detail page +- Adds feature flag functionality ## [0.13.1] - Added "Back to search results" link for works and editions diff --git a/src/__tests__/Search.test.tsx b/src/__tests__/Search.test.tsx index 570120aa..5a486bb3 100644 --- a/src/__tests__/Search.test.tsx +++ b/src/__tests__/Search.test.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, screen, within } from "@testing-library/react"; +import { render } from "./testUtils/render"; import SearchResults from "../components/Search/Search"; import { FacetItem, SearchField } from "../types/DataModel"; import { ApiSearchResult, SearchQuery } from "../types/SearchQuery"; @@ -623,3 +624,59 @@ describe("Renders search header correctly when viaf search is passed", () => { ); }); }); + +describe("Renders total works correctly when feature flag is set", () => { + beforeEach(() => { + act(() => { + resizeWindow(300, 1000); + Object.defineProperty(window, "sessionStorage", { + value: { + getItem: jest.fn(() => null), + setItem: jest.fn(() => null), + }, + writable: true, + }); + }); + }); + + test("Shown when feature flag query is true", () => { + mockRouter.push("?feature_totalCount=true"); + render( + + ); + expect(window.sessionStorage.getItem).toHaveBeenCalledTimes(1); + expect(window.sessionStorage.setItem).toHaveBeenCalledTimes(1); + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + "featureFlags", + JSON.stringify({ totalCount: true }) + ); + expect(screen.getByText("Total number of works: 26")).toBeInTheDocument(); + }); + + test("Not shown when feature flag query is false", () => { + mockRouter.push("?feature_totalCount=false"); + render( + + ); + expect(window.sessionStorage.getItem).toHaveBeenCalledTimes(1); + expect(window.sessionStorage.setItem).toHaveBeenCalledTimes(1); + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + "featureFlags", + JSON.stringify({ totalCount: false }) + ); + expect( + screen.queryByText("Total number of works: 26") + ).not.toBeInTheDocument(); + }); + + test("Not shown when feature flag query is not passed", () => { + render( + + ); + expect(window.sessionStorage.getItem).toHaveBeenCalledTimes(1); + expect(window.sessionStorage.setItem).toHaveBeenCalledTimes(1); + expect( + screen.queryByText("Total number of works: 26") + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/testUtils/render.tsx b/src/__tests__/testUtils/render.tsx new file mode 100644 index 00000000..d7e10444 --- /dev/null +++ b/src/__tests__/testUtils/render.tsx @@ -0,0 +1,13 @@ +import { render, RenderOptions } from "@testing-library/react"; +import { ReactElement } from "react"; +import { FeatureFlagProvider } from "~/src/context/FeatureFlagContext"; + +const customRender = ( + ui: ReactElement, + options?: Omit +) => { + render(ui, { wrapper: FeatureFlagProvider, ...options }); +}; + +export * from "@testing-library/react"; +export { customRender as render }; diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx index 8988b50e..a2b2028a 100644 --- a/src/components/Search/Search.tsx +++ b/src/components/Search/Search.tsx @@ -33,6 +33,8 @@ import ResultsSorts from "../ResultsSorts/ResultsSorts"; import { defaultBreadcrumbs } from "~/src/constants/labels"; import SearchHeader from "../SearchHeader/SearchHeader"; import { ApiWork } from "~/src/types/WorkQuery"; +import useFeatureFlags from "~/src/context/FeatureFlagContext"; +import TotalWorks from "../TotalWorks/TotalWorks"; const SearchResults: React.FC<{ searchQuery: SearchQuery; @@ -43,6 +45,7 @@ const SearchResults: React.FC<{ ...SearchQueryDefaults, ...props.searchQuery, }); + const { isFlagActive } = useFeatureFlags(); const { onClose, onOpen, Modal } = useModal(); @@ -173,6 +176,11 @@ const SearchResults: React.FC<{ + {isFlagActive("totalCount") && ( + + + + )} diff --git a/src/context/FeatureFlagContext.tsx b/src/context/FeatureFlagContext.tsx new file mode 100644 index 00000000..1853f527 --- /dev/null +++ b/src/context/FeatureFlagContext.tsx @@ -0,0 +1,86 @@ +import { useRouter } from "next/router"; +import { ParsedUrlQuery } from "querystring"; +import React, { createContext, useContext, useEffect, useState } from "react"; +import { extractQueryParam } from "../util/LinkUtils"; + +type FeatureFlag = { + [flag: string]: boolean; +}; + +type FeatureFlagContextType = { + featureFlags: FeatureFlag; + setFeatureFlags: (featureFlags: FeatureFlag) => void; + isFlagActive: (flag: string) => boolean; +}; + +export const FeatureFlagContext = + createContext(undefined); + +const extractFeatureFlagParams = (query: ParsedUrlQuery) => { + const featureFlags = {}; + for (const param in query) { + if (param.includes("feature_")) { + const featureFlag = param.split("_")[1]; + featureFlags[featureFlag] = JSON.parse(extractQueryParam(query, param)); + } + } + return featureFlags; +}; + +export const FeatureFlagProvider: React.FC = ({ children }) => { + const [featureFlags, setFeatureFlags] = useState({}); + const isFlagActive = (flag: string) => { + return featureFlags[flag]; + }; + + const router = useRouter(); + const { query } = router; + + useEffect(() => { + const storedFeatureFlagsStr = sessionStorage.getItem("featureFlags"); + let storedFeatureFlags: FeatureFlag = {}; + if (storedFeatureFlagsStr) { + try { + storedFeatureFlags = JSON.parse(storedFeatureFlagsStr); + for (const flag in storedFeatureFlags) { + const featureFlag = "feature_" + flag; + if (!query[featureFlag]) { + router.push({ + query: { ...query, [featureFlag]: storedFeatureFlags[flag] }, + }); + } + } + } catch (e) { + throw new Error(e); + } + } + + const newFeatureFlags = extractFeatureFlagParams(query); + sessionStorage.setItem("featureFlags", JSON.stringify(newFeatureFlags)); + setFeatureFlags(newFeatureFlags); + }, [query, router]); + + return ( + + {children} + + ); +}; + +export const useFeatureFlags = () => { + const context = useContext(FeatureFlagContext); + if (typeof context === "undefined") { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagProvider" + ); + } + return context; +}; + +export default useFeatureFlags; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 86728b1d..1f7335c4 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,6 +9,7 @@ import Head from "next/head"; import appConfig from "~/config/appConfig"; import { documentTitles } from "../constants/labels"; import "@nypl/web-reader/dist/esm/index.css"; +import { FeatureFlagProvider } from "../context/FeatureFlagContext"; /** * Determines if we are running on server or in the client. @@ -75,7 +76,9 @@ function MyApp({ Component, pageProps }: AppProps) { /> {/* */} - + + + ); } diff --git a/src/util/LinkUtils.ts b/src/util/LinkUtils.ts index 95e83f9d..aae8b84a 100644 --- a/src/util/LinkUtils.ts +++ b/src/util/LinkUtils.ts @@ -1,3 +1,5 @@ +import { ParsedUrlQuery } from "querystring"; + const isRefererInternal = (referer: string, host: string) => { return referer && referer.includes(host); }; @@ -11,3 +13,12 @@ export const getBackToSearchUrl = (referer: string, host: string) => { ? referer : null; }; + +export const extractQueryParam = ( + query: ParsedUrlQuery, + param: string +): string | undefined => { + const extracted = query?.[param]; + + return typeof extracted === "string" ? extracted : undefined; +}; diff --git a/src/util/__tests__/LinkUtils.test.tsx b/src/util/__tests__/LinkUtils.test.tsx index cbcb7c7c..5cce22db 100644 --- a/src/util/__tests__/LinkUtils.test.tsx +++ b/src/util/__tests__/LinkUtils.test.tsx @@ -1,4 +1,10 @@ -import { getBackToSearchUrl, getBackUrl } from "../LinkUtils"; +import { + extractQueryParam, + getBackToSearchUrl, + getBackUrl, +} from "../LinkUtils"; +import mockRouter from "next-router-mock"; +jest.mock("next/router", () => require("next-router-mock")); describe("Generates back url", () => { const host = "drb-qa.nypl.org"; @@ -31,3 +37,25 @@ describe("Generate back to serach url", () => { expect(backUrl).toEqual(referer); }); }); + +describe("Extracts query parameter from url", () => { + test("extractQueryParam returns a single flag", () => { + mockRouter.setCurrentUrl( + "https://drb-qa.nypl.org/edition/1780467?feature=true" + ); + const features = extractQueryParam(mockRouter.query, "feature"); + expect(features).toEqual("true"); + }); + test("extractQueryParam returns undefined if multiple flags have the same name", () => { + mockRouter.setCurrentUrl( + "https://drb-qa.nypl.org/edition/1780467?feature=false&feature=true" + ); + const features = extractQueryParam(mockRouter.query, "feature"); + expect(features).toBeUndefined(); + }); + test("extractQueryParam returns undefined if query doesn't exist", () => { + mockRouter.setCurrentUrl("https://drb-qa.nypl.org/edition/1780467"); + const features = extractQueryParam(mockRouter.query, "feature"); + expect(features).toBeUndefined(); + }); +});