From ffdd3f0ffcbabc1d204ef63572501375db9768a5 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Fri, 9 Aug 2024 10:08:56 -0400 Subject: [PATCH] Introduce modern context provider and hooks (PP-1572) (#123) --- src/components/ContextProvider.tsx | 11 ++- .../InventoryReportRequestModal.tsx | 21 +----- src/components/LibraryStats.tsx | 26 ++----- src/context/appContext.ts | 28 ++++++++ tests/jest/context/AppContext.test.tsx | 72 +++++++++++++++++++ 5 files changed, 119 insertions(+), 39 deletions(-) create mode 100644 src/context/appContext.ts create mode 100644 tests/jest/context/AppContext.test.tsx diff --git a/src/components/ContextProvider.tsx b/src/components/ContextProvider.tsx index e5487d406..ad36336c7 100644 --- a/src/components/ContextProvider.tsx +++ b/src/components/ContextProvider.tsx @@ -6,6 +6,7 @@ import { FeatureFlags, PathFor } from "../interfaces"; import Admin from "../models/Admin"; import PathForProvider from "@thepalaceproject/web-opds-client/lib/components/context/PathForContext"; import ActionCreator from "../actions"; +import AppContextProvider from "../context/appContext"; export interface ContextProviderProps extends React.Props { store?: Store; @@ -96,9 +97,17 @@ export default class ContextProvider extends React.Component< } render() { + const appContextValue = { + csrfToken: this.props.csrfToken, + settingUp: this.props.settingUp, + admin: this.admin, + featureFlags: this.props.featureFlags, + }; return ( - {React.Children.only(this.props.children) as JSX.Element} + + {React.Children.only(this.props.children) as JSX.Element} + ); } diff --git a/src/components/InventoryReportRequestModal.tsx b/src/components/InventoryReportRequestModal.tsx index 7c5bcf67e..5d88c0825 100644 --- a/src/components/InventoryReportRequestModal.tsx +++ b/src/components/InventoryReportRequestModal.tsx @@ -9,8 +9,7 @@ import { InventoryReportCollectionInfo, InventoryReportRequestParams, } from "../api/admin"; -import Admin from "../models/Admin"; -import * as PropTypes from "prop-types"; +import { useAppEmail } from "../context/appContext"; interface FormProps { show: boolean; @@ -37,15 +36,7 @@ const CANCEL_BUTTON_TITLE = "Cancel Report Request"; export const ACK_RESPONSE_BUTTON_CONTENT = "Ok"; const ACK_RESPONSE_BUTTON_TITLE = "Acknowledge Response"; -// Create a modal to request an inventory report and to describe outcome. -// *** To use the legacy context here, we need to create a `contextTypes` property on this function object -// *** and add `context` types to the function definition. -// *** InventoryReportRequestModal.contextTypes = { email: PropTypes.string } -// *** See: https://legacy.reactjs.org/docs/legacy-context.html#referencing-context-in-stateless-function-components -const InventoryReportRequestModal = ( - { show, onHide, library }: FormProps, - context: { admin: Admin } -) => { +const InventoryReportRequestModal = ({ show, onHide, library }: FormProps) => { const [showConfirmationModal, setShowConfirmationModal] = useState(true); const [showResponseModal, setShowResponseModal] = useState(false); const [responseMessage, setResponseMessage] = useState(null); @@ -68,7 +59,7 @@ const InventoryReportRequestModal = ( resetState(); }; - const { email } = context.admin; + const email = useAppEmail(); const { collections } = useReportInfo(show, { library }); return componentContent({ @@ -82,12 +73,6 @@ const InventoryReportRequestModal = ( responseMessage, }); }; -// TODO: This is needed to support legacy context provider on this component (see above). -// The overall approach should be replaced with another mechanism (e.g., `useContext` or -// `useSelector` if we move `email` to new context provider or Redux, respectively). -InventoryReportRequestModal.contextTypes = { - admin: PropTypes.object.isRequired, -}; type componentContentProps = { show: boolean; diff --git a/src/components/LibraryStats.tsx b/src/components/LibraryStats.tsx index eb3ee965e..5f2755efc 100644 --- a/src/components/LibraryStats.tsx +++ b/src/components/LibraryStats.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { useState } from "react"; import * as numeral from "numeral"; import { - FeatureFlags, InventoryStatistics, LibraryStatistics, PatronStatistics, @@ -19,8 +18,7 @@ import { import { Button } from "library-simplified-reusable-components"; import InventoryReportRequestModal from "./InventoryReportRequestModal"; import SingleStatListItem from "./SingleStatListItem"; -import * as PropTypes from "prop-types"; -import Admin from "../models/Admin"; +import { useAppAdmin, useAppFeatureFlags } from "../context/appContext"; export interface LibraryStatsProps { stats: LibraryStatistics; @@ -51,14 +49,10 @@ const inventoryKeyToLabelMap = { /** Displays statistics about patrons, licenses, and collections from the server, for a single library or all libraries the admin has access to. */ -// *** To use the legacy context here, we need to create a `contextTypes` property on this function object -// *** and add `context` types to the function definition. -// *** LibraryStats.contextTypes = { email: PropTypes.string } -// *** See: https://legacy.reactjs.org/docs/legacy-context.html#referencing-context-in-stateless-function-components -const LibraryStats = ( - props: LibraryStatsProps, - context: { admin: Admin; featureFlags: FeatureFlags } -) => { +const LibraryStats = (props: LibraryStatsProps) => { + const admin = useAppAdmin(); + const { reportsOnlyForSysadmins } = useAppFeatureFlags(); + const { stats, library } = props; const { name: libraryName, @@ -70,8 +64,7 @@ const LibraryStats = ( // A feature flag controls whether to show the inventory report form. const inventoryReportRequestEnabled = - !context.featureFlags.reportsOnlyForSysadmins || - context.admin.isSystemAdmin(); + !reportsOnlyForSysadmins || admin.isSystemAdmin(); const chartItems = collections ?.map(({ name, inventory, inventoryByMedium }) => ({ @@ -105,13 +98,6 @@ const LibraryStats = ( ); }; -// TODO: This is needed to support legacy context provider on this component (see above). -// The overall approach should be replaced with another mechanism (e.g., `useContext` or -// `useSelector` if we move `email` to new context provider or Redux, respectively). -LibraryStats.contextTypes = { - admin: PropTypes.object.isRequired, - featureFlags: PropTypes.object.isRequired, -}; const renderPatronsGroup = (patrons: PatronStatistics) => { return ( diff --git a/src/context/appContext.ts b/src/context/appContext.ts new file mode 100644 index 000000000..5b6d8e805 --- /dev/null +++ b/src/context/appContext.ts @@ -0,0 +1,28 @@ +import { createContext, useContext } from "react"; +import { FeatureFlags } from "../interfaces"; +import Admin from "../models/Admin"; + +export type AppContextType = { + csrfToken: string; + settingUp: boolean; + admin: Admin; + featureFlags: FeatureFlags; +}; + +// Don't export this, since we always want the error handling behavior of our hook. +const AppContext = createContext(undefined); + +export const useAppContext = (): AppContextType => { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error("useAppContext must be used within an AppContext povider."); + } + return context; +}; + +export const useCsrfToken = () => useAppContext().csrfToken; +export const useAppAdmin = () => useAppContext().admin; +export const useAppEmail = () => useAppAdmin().email; +export const useAppFeatureFlags = () => useAppContext().featureFlags; + +export default AppContext.Provider; diff --git a/tests/jest/context/AppContext.test.tsx b/tests/jest/context/AppContext.test.tsx new file mode 100644 index 000000000..c07194ad3 --- /dev/null +++ b/tests/jest/context/AppContext.test.tsx @@ -0,0 +1,72 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { + useAppAdmin, + useAppContext, + useAppEmail, + useAppFeatureFlags, + useCsrfToken, +} from "../../../src/context/appContext"; +import { componentWithProviders } from "../testUtils/withProviders"; +import { ContextProviderProps } from "../../../src/components/ContextProvider"; +import { FeatureFlags } from "../../../src/interfaces"; + +// TODO: These tests may need to be adjusted in the future. +// Currently, an AppContext.Provider is injected into the component tree +// by the ContextProvider, which itself uses a legacy context API. (See +// https://legacy.reactjs.org/docs/legacy-context.html) +// but that will change once uses of that API have been removed. + +describe("AppContext", () => { + const expectedCsrfToken = "token"; + const expectedEmail = "email"; + const expectedFeatureFlags: FeatureFlags = { + // @ts-expect-error - "testTrue" & "testFalse" aren't valid feature flags + testTrue: true, + testFalse: false, + }; + const expectedRoles = [{ role: "system" }]; + + const contextProviderProps: ContextProviderProps = { + csrfToken: expectedCsrfToken, + featureFlags: expectedFeatureFlags, + roles: expectedRoles, + email: expectedEmail, + }; + const wrapper = componentWithProviders({ contextProviderProps }); + + it("provides useAppContext context hook", () => { + const { result } = renderHook(() => useAppContext(), { wrapper }); + const value = result.current; + expect(value.csrfToken).toEqual(expectedCsrfToken); + expect(value.admin.email).toEqual(expectedEmail); + expect(value.admin.roles).toEqual(expectedRoles); + expect(value.featureFlags).toEqual(expectedFeatureFlags); + }); + + it("provides useAppAdmin context hook", () => { + const { result } = renderHook(() => useAppAdmin(), { wrapper }); + const admin = result.current; + expect(admin.email).toEqual(expectedEmail); + expect(admin.roles).toEqual(expectedRoles); + }); + + it("provides useAppEmail context hook", () => { + const { result } = renderHook(() => useAppEmail(), { wrapper }); + const email = result.current; + expect(email).toEqual(expectedEmail); + }); + + it("provides useCsrfToken context hook", () => { + const { result } = renderHook(() => useCsrfToken(), { wrapper }); + const token = result.current; + expect(token).toEqual(expectedCsrfToken); + }); + + it("provides useAppFeatureFlags context hook", () => { + const { result } = renderHook(() => useAppFeatureFlags(), { + wrapper, + }); + const flags = result.current; + expect(flags).toEqual(expectedFeatureFlags); + }); +});