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();
+ });
+});