From f54d23160660c887ddff270216515c972d0e26fa Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Thu, 22 Aug 2024 10:23:56 -0400 Subject: [PATCH] Usage/reports component and overall dashboard layout. (PP-1534) (#126) --- src/components/ContextProvider.tsx | 11 +- src/components/LibraryStats.tsx | 69 +++++--- src/components/StatsUsageReportsGroup.tsx | 84 +++++++++ src/context/appContext.ts | 1 + src/index.tsx | 8 +- src/interfaces.ts | 4 + src/stylesheets/stats.scss | 69 ++++++-- tests/jest/components/Stats.test.tsx | 198 ++++++++++++++-------- 8 files changed, 333 insertions(+), 111 deletions(-) create mode 100644 src/components/StatsUsageReportsGroup.tsx diff --git a/src/components/ContextProvider.tsx b/src/components/ContextProvider.tsx index ad36336c7..faa6d3cac 100644 --- a/src/components/ContextProvider.tsx +++ b/src/components/ContextProvider.tsx @@ -6,8 +6,13 @@ 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"; +import AppContextProvider, { AppContextType } from "../context/appContext"; +// Note: Not all elements of these props make it into the `ContextProvider`. +// Some are exposed only through the `AppContextProvider` component (which +// this component wraps. +// TODO: We should get this interface to the point where we can just extend +// the `ConfigurationSettings` interface. export interface ContextProviderProps extends React.Props { store?: Store; csrfToken: string; @@ -19,6 +24,7 @@ export interface ContextProviderProps extends React.Props { library?: string; }[]; featureFlags: FeatureFlags; + quicksightPagePath?: string; } /** Provides a redux store, configuration options, and a function to create URLs @@ -97,11 +103,12 @@ export default class ContextProvider extends React.Component< } render() { - const appContextValue = { + const appContextValue: AppContextType = { csrfToken: this.props.csrfToken, settingUp: this.props.settingUp, admin: this.admin, featureFlags: this.props.featureFlags, + quicksightPagePath: this.props.quicksightPagePath, }; return ( diff --git a/src/components/LibraryStats.tsx b/src/components/LibraryStats.tsx index 46f3360d9..245e10eb9 100644 --- a/src/components/LibraryStats.tsx +++ b/src/components/LibraryStats.tsx @@ -8,6 +8,8 @@ import StatsTotalCirculationsGroup from "./StatsTotalCirculationsGroup"; import StatsPatronGroup from "./StatsPatronGroup"; import StatsInventoryGroup from "./StatsInventoryGroup"; import StatsCollectionsGroup from "./StatsCollectionsGroup"; +import StatsUsageReportsGroup from "./StatsUsageReportsGroup"; +import { useAppContext } from "../context/appContext"; export interface LibraryStatsProps { stats: LibraryStatistics; @@ -43,15 +45,23 @@ const LibraryStats = ({ stats, library }: LibraryStatsProps) => { const inventoryReportRequestEnabled = useMayRequestInventoryReports({ library, }); - const dashboardTitle = library - ? `${libraryName || libraryKey} Dashboard` - : ALL_LIBRARIES_HEADING; - const libraryOrLibraries = library ? "library's" : "libraries'"; + const quicksightPageUrl = useAppContext().quicksightPagePath; + + let statsLayoutClass: string, dashboardTitle: string, implementation: string; + if (library) { + dashboardTitle = `${libraryName || libraryKey} Dashboard`; + statsLayoutClass = "stats-with-library"; + implementation = "library's implementation"; + } else { + dashboardTitle = ALL_LIBRARIES_HEADING; + statsLayoutClass = "stats-without-library"; + implementation = "libraries' implementations"; + } return (

{dashboardTitle}

-
    -
  • +
      +
    • { description="Real-time patron circulation information of the Palace System." />
    • -
    • - -
    • -
    • - -
    • -
    • + {!!library && ( +
    • + +
    • + )} + {!library && ( + <> +
    • + +
    • +
    • + +
    • + + )} +
    • { + const [showReportForm, setShowReportForm] = useState(false); + + return ( + <> + {inventoryReportsEnabled && library && ( + setShowReportForm(false)} + library={library} + /> + )} +
        +
      • + + <> + {inventoryReportsEnabled && library && ( +
      • +
      • + +
      • +
      + + ); +}; + +export default StatsUsageReportsGroup; diff --git a/src/context/appContext.ts b/src/context/appContext.ts index 5b6d8e805..fb636a855 100644 --- a/src/context/appContext.ts +++ b/src/context/appContext.ts @@ -7,6 +7,7 @@ export type AppContextType = { settingUp: boolean; admin: Admin; featureFlags: FeatureFlags; + quicksightPagePath: string; }; // Don't export this, since we always want the error handling behavior of our hook. diff --git a/src/index.tsx b/src/index.tsx index 90280550d..25b6377e9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -27,7 +27,6 @@ class CirculationAdmin { const div = document.createElement("div"); div.id = "opds-catalog"; div.className = "palace"; - config.featureFlags = { ...defaultFeatureFlags, ...config.featureFlags }; document.getElementsByTagName("body")[0].appendChild(div); const catalogEditorPath = @@ -36,10 +35,15 @@ class CirculationAdmin { "/admin/web/lists(/:library)(/:editOrCreate)(/:identifier)"; const lanePagePath = "/admin/web/lanes(/:library)(/:editOrCreate)(/:identifier)"; + const quicksightPagePath = "/admin/web/quicksight"; const queryClient = new QueryClient(); const store = buildStore(); + + config.featureFlags = { ...defaultFeatureFlags, ...config.featureFlags }; + config.quicksightPagePath = quicksightPagePath; + const appElement = "opds-catalog"; const app = config.settingUp ? ( @@ -63,7 +67,7 @@ class CirculationAdmin { component={DashboardPage} /> { Response, }); + const statGroupToHeading = { + patrons: "Current Circulation Activity", + circulations: "Circulation Totals", + inventory: "Inventory", + usageReports: "Usage and Reports", + collections: "Configured Collections", + }; + describe("query hook correctly handles fetch responses", () => { const wrapper = componentWithProviders(); @@ -153,93 +161,134 @@ describe("Dashboard Statistics", () => { afterAll(() => { fetchMock.restore(); }); - afterEach(() => { - fetchMock.resetHistory(); - }); - const assertLoadingState = ({ getByRole }) => { - getByRole("dialog", { name: "Loading" }); - getByRole("heading", { level: 1, name: "Loading" }); - }; - const assertNotLoadingState = ({ queryByRole }) => { - const missingLoadingDialog = queryByRole("dialog", { name: "Loading" }); - const missingLoadingHeading = queryByRole("heading", { - level: 1, - name: "Loading", + describe("correctly handles fetching and caching", () => { + afterEach(() => { + fetchMock.resetHistory(); }); - expect(missingLoadingDialog).not.toBeInTheDocument(); - expect(missingLoadingHeading).not.toBeInTheDocument(); - }; - it("shows/hides the loading indicator", async () => { - // We haven't tried to fetch anything yet. - expect(fetchMock.calls()).toHaveLength(0); + const assertLoadingState = ({ getByRole }) => { + getByRole("dialog", { name: "Loading" }); + getByRole("heading", { level: 1, name: "Loading" }); + }; + const assertNotLoadingState = ({ queryByRole }) => { + const missingLoadingDialog = queryByRole("dialog", { name: "Loading" }); + const missingLoadingHeading = queryByRole("heading", { + level: 1, + name: "Loading", + }); + expect(missingLoadingDialog).not.toBeInTheDocument(); + expect(missingLoadingHeading).not.toBeInTheDocument(); + }; - const { rerender, getByRole, queryByRole } = renderWithProviders( - - ); + it("shows/hides the loading indicator", async () => { + // We haven't tried to fetch anything yet. + expect(fetchMock.calls()).toHaveLength(0); - // We should start in the loading state. - assertLoadingState({ getByRole }); + const { rerender, getByRole, queryByRole } = renderWithProviders( + + ); - // Wait a tick for the statistics to render. - await new Promise(process.nextTick); - // Now we've fetched something. - expect(fetchMock.calls()).toHaveLength(1); + // We should start in the loading state. + assertLoadingState({ getByRole }); - rerender(); + // Wait a tick for the statistics to render. + await new Promise(process.nextTick); + // Now we've fetched something. + expect(fetchMock.calls()).toHaveLength(1); - // We should show our content without the loading state. - assertNotLoadingState({ queryByRole }); - getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); + rerender(); - // We haven't made another call, since the response is cached. - expect(fetchMock.calls()).toHaveLength(1); - }); + // We should show our content without the loading state. + assertNotLoadingState({ queryByRole }); + getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); - it("doesn't fetch again, because response is cached", async () => { - const { getByRole, queryByRole } = renderWithProviders(); + // We haven't made another call, since the response is cached. + expect(fetchMock.calls()).toHaveLength(1); + }); - // We should show our content immediately, without entering the loading state. - assertNotLoadingState({ queryByRole }); - getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); + it("doesn't fetch again, because response is cached", async () => { + const { getByRole, queryByRole } = renderWithProviders(); - // We never tried to fetch anything because the result is cached. - expect(fetchMock.calls()).toHaveLength(0); - }); + // We should show our content immediately, without entering the loading state. + assertNotLoadingState({ queryByRole }); + getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); - it("show stats for a library, if a library is specified", async () => { - const { getByRole, queryByRole, getByText } = renderWithProviders( - - ); + // We never tried to fetch anything because the result is cached. + expect(fetchMock.calls()).toHaveLength(0); + }); - // We should show our content immediately, without entering the loading state. - assertNotLoadingState({ queryByRole }); - getByRole("heading", { - level: 2, - name: `${sampleLibraryName} Dashboard`, + it("show stats for a library, if a library is specified", async () => { + const { getByRole, queryByRole, getByText } = renderWithProviders( + + ); + + // We should show our content immediately, without entering the loading state. + assertNotLoadingState({ queryByRole }); + getByRole("heading", { + level: 2, + name: `${sampleLibraryName} Dashboard`, + }); + getByRole("heading", { level: 3, name: statGroupToHeading.patrons }); + getByText("21"); + + // We never tried to fetch anything because the result is cached. + expect(fetchMock.calls()).toHaveLength(0); }); - getByRole("heading", { level: 3, name: "Current Circulation Activity" }); - getByText("623"); - // We never tried to fetch anything because the result is cached. - expect(fetchMock.calls()).toHaveLength(0); - }); + it("shows site-wide stats when no library specified", async () => { + const { getByRole, getByText, queryByRole } = renderWithProviders( + + ); - it("shows site-wide stats when no library specified", async () => { - const { getByRole, getByText, queryByRole } = renderWithProviders( - - ); + // We should show our content immediately, without entering the loading state. + assertNotLoadingState({ queryByRole }); + + getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); + getByRole("heading", { + level: 3, + name: "Current Circulation Activity", + }); + getByText("1.6k"); - // We should show our content immediately, without entering the loading state. - assertNotLoadingState({ queryByRole }); + // We never tried to fetch anything because the result is cached. + expect(fetchMock.calls()).toHaveLength(0); + }); + }); - getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); - getByRole("heading", { level: 3, name: "Current Circulation Activity" }); - getByText("1.6k"); + describe("has correct statistics groups", () => { + it("shows the right groups with a library", () => { + const { getAllByRole } = renderWithProviders( + + ); + + const groupHeadings = getAllByRole("heading", { level: 3 }); + const expectedHeadings = [ + statGroupToHeading.patrons, + statGroupToHeading.usageReports, + statGroupToHeading.collections, + ]; + expect(groupHeadings).toHaveLength(3); + groupHeadings.forEach((heading, index) => { + expect(heading).toHaveTextContent(expectedHeadings[index]); + }); + }); - // We never tried to fetch anything because the result is cached. - expect(fetchMock.calls()).toHaveLength(0); + it("shows the right groups without a library", () => { + const { getAllByRole } = renderWithProviders(); + + const groupHeadings = getAllByRole("heading", { level: 3 }); + const expectedHeadings = [ + statGroupToHeading.patrons, + statGroupToHeading.circulations, + statGroupToHeading.inventory, + statGroupToHeading.collections, + ]; + expect(groupHeadings).toHaveLength(4); + groupHeadings.forEach((heading, index) => { + expect(heading).toHaveTextContent(expectedHeadings[index]); + }); + }); }); }); @@ -256,9 +305,11 @@ describe("Dashboard Statistics", () => { const managerAll = [{ role: "manager-all" }]; const librarianAll = [{ role: "librarian-all" }]; + const fakeQuickSightHref = "https://example.com/fakeQS"; const baseContextProviderProps = { csrfToken: "", featureFlags: { reportsOnlyForSysadmins: false }, + quicksightPagePath: fakeQuickSightHref, }; const renderFor = ( @@ -271,12 +322,21 @@ describe("Dashboard Statistics", () => { roles, }; - const { container, queryByRole } = renderWithProviders( + const { + container, + getByRole, + queryByRole, + } = renderWithProviders( , { contextProviderProps } ); - const result = queryByRole("button", { name: "⬇︎" }); + // We should always render a Usage reports group when a library is specified. + getByRole("heading", { level: 3, name: statGroupToHeading.usageReports }); + const usageReportLink = getByRole("link", { name: /View Usage/i }); + expect(usageReportLink).toHaveAttribute("href", fakeQuickSightHref); + + const result = queryByRole("button", { name: /Request Report/i }); // Clean up the container after each render. document.body.removeChild(container); return result;