Skip to content

Commit

Permalink
Introduce modern context provider and hooks (PP-1572) (#123)
Browse files Browse the repository at this point in the history
  • Loading branch information
tdilauro authored Aug 9, 2024
1 parent 25378bb commit ffdd3f0
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 39 deletions.
11 changes: 10 additions & 1 deletion src/components/ContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContextProvider> {
store?: Store<RootState>;
Expand Down Expand Up @@ -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 (
<PathForProvider pathFor={this.pathFor}>
{React.Children.only(this.props.children) as JSX.Element}
<AppContextProvider value={appContextValue}>
{React.Children.only(this.props.children) as JSX.Element}
</AppContextProvider>
</PathForProvider>
);
}
Expand Down
21 changes: 3 additions & 18 deletions src/components/InventoryReportRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -68,7 +59,7 @@ const InventoryReportRequestModal = (
resetState();
};

const { email } = context.admin;
const email = useAppEmail();
const { collections } = useReportInfo(show, { library });

return componentContent({
Expand All @@ -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;
Expand Down
26 changes: 6 additions & 20 deletions src/components/LibraryStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as React from "react";
import { useState } from "react";
import * as numeral from "numeral";
import {
FeatureFlags,
InventoryStatistics,
LibraryStatistics,
PatronStatistics,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 }) => ({
Expand Down Expand Up @@ -105,13 +98,6 @@ const LibraryStats = (
</div>
);
};
// 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 (
Expand Down
28 changes: 28 additions & 0 deletions src/context/appContext.ts
Original file line number Diff line number Diff line change
@@ -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<AppContextType | undefined>(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;
72 changes: 72 additions & 0 deletions tests/jest/context/AppContext.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit ffdd3f0

Please sign in to comment.