Skip to content

Commit

Permalink
Merge pull request #337 from NYPL/SFR-1446_feature_flags
Browse files Browse the repository at this point in the history
SFR-1446: Implement feature flags
  • Loading branch information
jackiequach authored Jul 15, 2022
2 parents 21cd4b8 + b712165 commit f79931f
Show file tree
Hide file tree
Showing 8 changed files with 210 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion src/__tests__/Search.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
<SearchResults searchQuery={searchQuery} searchResults={searchResults} />
);
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(
<SearchResults searchQuery={searchQuery} searchResults={searchResults} />
);
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(
<SearchResults searchQuery={searchQuery} searchResults={searchResults} />
);
expect(window.sessionStorage.getItem).toHaveBeenCalledTimes(1);
expect(window.sessionStorage.setItem).toHaveBeenCalledTimes(1);
expect(
screen.queryByText("Total number of works: 26")
).not.toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions src/__tests__/testUtils/render.tsx
Original file line number Diff line number Diff line change
@@ -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<RenderOptions, "wrapper">
) => {
render(ui, { wrapper: FeatureFlagProvider, ...options });
};

export * from "@testing-library/react";
export { customRender as render };
8 changes: 8 additions & 0 deletions src/components/Search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,6 +45,7 @@ const SearchResults: React.FC<{
...SearchQueryDefaults,
...props.searchQuery,
});
const { isFlagActive } = useFeatureFlags();

const { onClose, onOpen, Modal } = useModal();

Expand Down Expand Up @@ -173,6 +176,11 @@ const SearchResults: React.FC<{
</TemplateBreakout>
<TemplateContent sidebar="left">
<TemplateContentTop>
{isFlagActive("totalCount") && (
<Box float="right">
<TotalWorks totalWorks={numberOfWorks} />
</Box>
)}
<Box className="search-heading">
<Box role="alert">
<Heading level="one" id="page-title-heading">
Expand Down
86 changes: 86 additions & 0 deletions src/context/FeatureFlagContext.tsx
Original file line number Diff line number Diff line change
@@ -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<FeatureFlagContextType>(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<FeatureFlag>({});
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 (
<FeatureFlagContext.Provider
value={{
featureFlags: featureFlags,
setFeatureFlags: setFeatureFlags,
isFlagActive,
}}
>
{children}
</FeatureFlagContext.Provider>
);
};

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;
5 changes: 4 additions & 1 deletion src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -75,7 +76,9 @@ function MyApp({ Component, pageProps }: AppProps) {
/>
{/* <!-- End Google Analytics --> */}
</Head>
<Component {...pageProps} />
<FeatureFlagProvider>
<Component {...pageProps} />
</FeatureFlagProvider>
</>
);
}
Expand Down
11 changes: 11 additions & 0 deletions src/util/LinkUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ParsedUrlQuery } from "querystring";

const isRefererInternal = (referer: string, host: string) => {
return referer && referer.includes(host);
};
Expand All @@ -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;
};
30 changes: 29 additions & 1 deletion src/util/__tests__/LinkUtils.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
});
});

1 comment on commit f79931f

@vercel
Copy link

@vercel vercel bot commented on f79931f Jul 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.