From 668f286009a59ac6e5f95f1a91e98376c75b9f9d Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Thu, 15 Aug 2024 09:42:47 -0400 Subject: [PATCH] Configured collections list and barchart (PP-1535, PP-1536 & PP-1537) (#124) * Restrict roles to allowed values. * Add some business rule hooks. * Configured collections refactor. * Factor out a StatsGroup component. * Update patron stats and factor out more components. --- src/businessRules/roleBasedAccess.ts | 27 ++ src/components/LibraryStats.tsx | 392 +++--------------- src/components/SingleStatListItem.tsx | 4 +- src/components/StatsCollectionsBarChart.tsx | 189 +++++++++ src/components/StatsCollectionsGroup.tsx | 36 ++ src/components/StatsCollectionsList.tsx | 24 ++ src/components/StatsGroup.tsx | 28 ++ src/components/StatsInventoryGroup.tsx | 90 ++++ src/components/StatsPatronGroup.tsx | 45 ++ .../StatsTotalCirculationsGroup.tsx | 36 ++ .../IndividualAdminEditForm-test.tsx | 23 +- .../__tests__/LibraryStats-test.tsx | 69 ++- src/index.tsx | 35 +- src/interfaces.ts | 39 +- src/stylesheets/stats.scss | 41 +- src/utils/sharedFunctions.ts | 26 ++ .../businessRules/roleBasedAccess.test.ts | 158 +++++++ tests/jest/components/Stats.test.tsx | 19 +- 18 files changed, 872 insertions(+), 409 deletions(-) create mode 100644 src/businessRules/roleBasedAccess.ts create mode 100644 src/components/StatsCollectionsBarChart.tsx create mode 100644 src/components/StatsCollectionsGroup.tsx create mode 100644 src/components/StatsCollectionsList.tsx create mode 100644 src/components/StatsGroup.tsx create mode 100644 src/components/StatsInventoryGroup.tsx create mode 100644 src/components/StatsPatronGroup.tsx create mode 100644 src/components/StatsTotalCirculationsGroup.tsx create mode 100644 tests/jest/businessRules/roleBasedAccess.test.ts diff --git a/src/businessRules/roleBasedAccess.ts b/src/businessRules/roleBasedAccess.ts new file mode 100644 index 000000000..1098fd29f --- /dev/null +++ b/src/businessRules/roleBasedAccess.ts @@ -0,0 +1,27 @@ +import { useAppAdmin, useAppFeatureFlags } from "../context/appContext"; + +// Not all methods taking this type will use the specified properties. +// The type is here to ensure that the call site provides the right +// properties, in case the rules change in the future. +type HasLibraryKeyProps = { + library: string; // library "key" or "short name" + [key: string]: unknown; +}; + +// If the `reportsOnlyForSysadmins` feature flag is set, only system admins +// may request inventory reports. +export const useMayRequestInventoryReports = ( + _: HasLibraryKeyProps +): boolean => { + const admin = useAppAdmin(); + const onlyForSysAdmins = useAppFeatureFlags().reportsOnlyForSysadmins; + return !onlyForSysAdmins || admin.isSystemAdmin(); +}; + +// Only system admins may view the collection statistics barchart. +export const useMayViewCollectionBarChart = ( + _: HasLibraryKeyProps +): boolean => { + const admin = useAppAdmin(); + return admin.isSystemAdmin(); +}; diff --git a/src/components/LibraryStats.tsx b/src/components/LibraryStats.tsx index 5f2755efc..46f3360d9 100644 --- a/src/components/LibraryStats.tsx +++ b/src/components/LibraryStats.tsx @@ -1,41 +1,20 @@ import * as React from "react"; -import { useState } from "react"; -import * as numeral from "numeral"; +import { LibraryStatistics } from "../interfaces"; import { - InventoryStatistics, - LibraryStatistics, - PatronStatistics, -} from "../interfaces"; -import { - Bar, - BarChart, - ResponsiveContainer, - Tooltip, - TooltipProps, - XAxis, - YAxis, -} from "recharts"; -import { Button } from "library-simplified-reusable-components"; -import InventoryReportRequestModal from "./InventoryReportRequestModal"; -import SingleStatListItem from "./SingleStatListItem"; -import { useAppAdmin, useAppFeatureFlags } from "../context/appContext"; + useMayRequestInventoryReports, + useMayViewCollectionBarChart, +} from "../businessRules/roleBasedAccess"; +import StatsTotalCirculationsGroup from "./StatsTotalCirculationsGroup"; +import StatsPatronGroup from "./StatsPatronGroup"; +import StatsInventoryGroup from "./StatsInventoryGroup"; +import StatsCollectionsGroup from "./StatsCollectionsGroup"; export interface LibraryStatsProps { stats: LibraryStatistics; library?: string; } -type OneLevelStatistics = { [key: string]: number }; -type TwoLevelStatistics = { [key: string]: OneLevelStatistics }; -type chartTooltipData = { - dataKey: string; - name?: string; - value: number | string; - color?: string; - perMedium?: OneLevelStatistics; -}; - -const inventoryKeyToLabelMap = { +export const inventoryKeyToLabelMap = { titles: "Titles", availableTitles: "Available Titles", openAccessTitles: "Open Access Titles", @@ -47,13 +26,11 @@ const inventoryKeyToLabelMap = { selfHostedTitles: "Self-Hosted Titles", }; +export const ALL_LIBRARIES_HEADING = "Dashboard for All Authorized Libraries"; + /** Displays statistics about patrons, licenses, and collections from the server, for a single library or all libraries the admin has access to. */ -const LibraryStats = (props: LibraryStatsProps) => { - const admin = useAppAdmin(); - const { reportsOnlyForSysadmins } = useAppFeatureFlags(); - - const { stats, library } = props; +const LibraryStats = ({ stats, library }: LibraryStatsProps) => { const { name: libraryName, key: libraryKey, @@ -62,323 +39,54 @@ const LibraryStats = (props: LibraryStatsProps) => { patronStatistics: patrons, } = stats || {}; - // A feature flag controls whether to show the inventory report form. - const inventoryReportRequestEnabled = - !reportsOnlyForSysadmins || admin.isSystemAdmin(); - - const chartItems = collections - ?.map(({ name, inventory, inventoryByMedium }) => ({ - name, - ...inventory, - _by_medium: inventoryByMedium || {}, - })) - .sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); - + const showBarChart = useMayViewCollectionBarChart({ library }); + const inventoryReportRequestEnabled = useMayRequestInventoryReports({ + library, + }); + const dashboardTitle = library + ? `${libraryName || libraryKey} Dashboard` + : ALL_LIBRARIES_HEADING; + const libraryOrLibraries = library ? "library's" : "libraries'"; return (
- {library ? ( -

{libraryName || libraryKey} Statistics

- ) : ( -

Statistics for All Libraries

- )} +

{dashboardTitle}

-
- ); -}; - -const renderPatronsGroup = (patrons: PatronStatistics) => { - return ( - <> -

- Patrons -

- - - ); -}; -const renderCirculationsGroup = (patrons: PatronStatistics) => { - return ( - <> -

- Circulation -

- - - ); -}; - -const renderInventoryGroup = ( - inventory: InventoryStatistics, - inventoryReportsEnabled: boolean, - library?: string -) => { - const [showReportForm, setShowReportForm] = useState(false); - - return ( - <> - {inventoryReportsEnabled && library && ( - setShowReportForm(false)} - library={library} - /> - )} -

- Inventory - {inventoryReportsEnabled && library && ( -

- - - ); -}; - -const renderCollectionsGroup = (chartItems) => { - return chartItems.length === 0 ? ( -

No associated collections.

- ) : ( - <> -

- Collections -

- - - - - } /> - +
  • + - +
  • + - +
  • + - - - - ); -}; - -/* Customize the Rechart tooltip to provide additional information */ -export const CustomTooltip = ({ - active, - payload, - label: collectionName, -}: TooltipProps) => { - if (!active) { - return null; - } - - // Nab inventory data from one of the chart payload objects. - // This corresponds to the Barcode `data` element for the current collection. - const chartItem = payload[0].payload; - - const propertyCountsByMedium = chartItem._by_medium || {}; - const mediumCountsByProperty: TwoLevelStatistics = Object.entries( - propertyCountsByMedium - ).reduce((acc, [key, value]) => { - Object.entries(value).forEach(([innerKey, innerValue]) => { - acc[innerKey] = acc[innerKey] || {}; - acc[innerKey][key] = innerValue; - }); - return acc; - }, {}); - const aboveTheLineColor = "#030303"; - const belowTheLineColor = "#A0A0A0"; - const aboveTheLine: chartTooltipData[] = [ - { - dataKey: "titles", - name: inventoryKeyToLabelMap.titles, - value: chartItem.titles, - perMedium: mediumCountsByProperty["titles"], - }, - { - dataKey: "availableTitles", - name: inventoryKeyToLabelMap.availableTitles, - value: chartItem.availableTitles, - perMedium: mediumCountsByProperty["availableTitles"], - }, - ...payload.filter(({ value }) => value > 0), - ].map(({ dataKey, name, value }) => { - const key = dataKey.toString(); - const perMedium = mediumCountsByProperty[key]; - return { dataKey: key, name, value, color: aboveTheLineColor, perMedium }; - }); - const aboveTheLineKeys = [ - "name", - ...aboveTheLine.map(({ dataKey }) => dataKey), - ]; - const belowTheLine = Object.entries(chartItem) - .filter(([key]) => !aboveTheLineKeys.includes(key)) - .filter(([key]) => !key.startsWith("_")) - .map(([dataKey, value]) => { - const key = dataKey.toString(); - const perMedium = mediumCountsByProperty[key]; - return { - dataKey: key, - name: inventoryKeyToLabelMap[key], - value: - typeof value === "number" - ? value - : typeof value === "string" - ? value - : "", - color: belowTheLineColor, - perMedium, - }; - }); - - // Render our custom tooltip. - return ( -
    -
    -

    {collectionName}

    - {renderChartTooltipPayload(aboveTheLine)} -
    - {renderChartTooltipPayload(belowTheLine)} -
    +
  • + ); }; -const renderChartTooltipPayload = (payload: Partial[]) => { - return payload.map( - ({ dataKey = "", name = "", value = "", color, perMedium = {} }) => ( -

    - {!!name && {name}:} - {formatNumber(value)} - {perMediumBreakdown(perMedium)} -

    - ) - ); -}; - -const perMediumBreakdown = (perMedium: OneLevelStatistics) => { - const perMediumLabels = Object.entries(perMedium) - .filter(([, count]) => count > 0) - .map(([medium, count]) => `${medium}: ${formatNumber(count)}`); - return ( - !!perMediumLabels.length && ( - - {` (${perMediumLabels.join(", ")})`} - - ) - ); -}; - -export const formatNumber = (n: number | string | null): string => { - // Format numbers using US conventions. - // Else return non-numeric strings as-is. - // Otherwise, return an empty string. - return !isNaN(Number(n)) - ? Intl.NumberFormat("en-US").format(Number(n)) - : n === String(n) - ? n - : ""; -}; - -export const humanNumber = (n: number): string => - n ? numeral(n).format("0.[0]a") : "0"; - export default LibraryStats; diff --git a/src/components/SingleStatListItem.tsx b/src/components/SingleStatListItem.tsx index a43a262b7..85352da38 100644 --- a/src/components/SingleStatListItem.tsx +++ b/src/components/SingleStatListItem.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as numeral from "numeral"; -import { formatNumber, humanNumber } from "./LibraryStats"; +import { formatNumber, roundedNumber } from "../utils/sharedFunctions"; export interface SingleStatListItemProps { label: string; @@ -11,7 +11,7 @@ export interface SingleStatListItemProps { const SingleStatListItem = (props: SingleStatListItemProps) => { const baseStat = ( <> - {humanNumber(props.value)} + {roundedNumber(props.value)} {props.label} ); diff --git a/src/components/StatsCollectionsBarChart.tsx b/src/components/StatsCollectionsBarChart.tsx new file mode 100644 index 000000000..6bb0f48d7 --- /dev/null +++ b/src/components/StatsCollectionsBarChart.tsx @@ -0,0 +1,189 @@ +import * as React from "react"; +import { CollectionInventory } from "../interfaces"; +import { + Bar, + BarChart, + ResponsiveContainer, + Tooltip, + TooltipProps, + XAxis, + YAxis, +} from "recharts"; +import { inventoryKeyToLabelMap } from "./LibraryStats"; +import { formatNumber } from "../utils/sharedFunctions"; + +const StatsCollectionsBarChart = ({ collections }: Props) => { + const chartItems = collections + ?.map(({ name, inventory, inventoryByMedium }) => ({ + name, + ...inventory, + _by_medium: inventoryByMedium || {}, + })) + .sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); + + return ( + + + + + } /> + + + + + + ); +}; + +type OneLevelStatistics = { [key: string]: number }; +type TwoLevelStatistics = { [key: string]: OneLevelStatistics }; +type chartTooltipData = { + dataKey: string; + name?: string; + value: number | string; + color?: string; + perMedium?: OneLevelStatistics; +}; + +type Props = { + collections: CollectionInventory[]; +}; +/* Customize the Rechart tooltip to provide additional information */ +export const CustomTooltip = ({ + active, + payload, + label: collectionName, +}: TooltipProps) => { + if (!active) { + return null; + } + + // Nab inventory data from one of the chart payload objects. This + // corresponds to the bar chart `data` element for the current collection. + const chartItem = payload[0].payload; + + const propertyCountsByMedium = chartItem._by_medium || {}; + const mediumCountsByProperty: TwoLevelStatistics = Object.entries( + propertyCountsByMedium + ).reduce((acc, [key, value]) => { + Object.entries(value).forEach(([innerKey, innerValue]) => { + acc[innerKey] = acc[innerKey] || {}; + acc[innerKey][key] = innerValue; + }); + return acc; + }, {}); + const aboveTheLineColor = "#030303"; + const belowTheLineColor = "#A0A0A0"; + const aboveTheLine: chartTooltipData[] = [ + { + dataKey: "titles", + name: inventoryKeyToLabelMap.titles, + value: chartItem.titles, + perMedium: mediumCountsByProperty["titles"], + }, + { + dataKey: "availableTitles", + name: inventoryKeyToLabelMap.availableTitles, + value: chartItem.availableTitles, + perMedium: mediumCountsByProperty["availableTitles"], + }, + ...payload.filter(({ value }) => value > 0), + ].map(({ dataKey, name, value }) => { + const key = dataKey.toString(); + const perMedium = mediumCountsByProperty[key]; + return { dataKey: key, name, value, color: aboveTheLineColor, perMedium }; + }); + const aboveTheLineKeys = [ + "name", + ...aboveTheLine.map(({ dataKey }) => dataKey), + ]; + const belowTheLine = Object.entries(chartItem) + .filter(([key]) => !aboveTheLineKeys.includes(key)) + .filter(([key]) => !key.startsWith("_")) + .map(([dataKey, value]) => { + const key = dataKey.toString(); + const perMedium = mediumCountsByProperty[key]; + return { + dataKey: key, + name: inventoryKeyToLabelMap[key], + value: + typeof value === "number" + ? value + : typeof value === "string" + ? value + : "", + color: belowTheLineColor, + perMedium, + }; + }); + + // Render our custom tooltip. + return ( +
    +
    +

    {collectionName}

    + {renderChartTooltipPayload(aboveTheLine)} +
    + {renderChartTooltipPayload(belowTheLine)} +
    +
    + ); +}; + +const renderChartTooltipPayload = (payload: Partial[]) => { + return payload.map( + ({ dataKey = "", name = "", value = "", color, perMedium = {} }) => ( +

    + {!!name && {name}:} + {formatNumber(value)} + {perMediumBreakdown(perMedium)} +

    + ) + ); +}; + +const perMediumBreakdown = (perMedium: OneLevelStatistics) => { + const perMediumLabels = Object.entries(perMedium) + .filter(([, count]) => count > 0) + .map(([medium, count]) => `${medium}: ${formatNumber(count)}`); + return ( + !!perMediumLabels.length && ( + + {` (${perMediumLabels.join(", ")})`} + + ) + ); +}; + +export default StatsCollectionsBarChart; diff --git a/src/components/StatsCollectionsGroup.tsx b/src/components/StatsCollectionsGroup.tsx new file mode 100644 index 000000000..797790336 --- /dev/null +++ b/src/components/StatsCollectionsGroup.tsx @@ -0,0 +1,36 @@ +import React = require("react"); +import StatsGroup from "./StatsGroup"; +import SingleStatListItem from "./SingleStatListItem"; +import { CollectionInventory } from "../interfaces"; +import StatsCollectionsBarChart from "./StatsCollectionsBarChart"; +import StatsCollectionsList from "./StatsCollectionsList"; + +type Props = { + heading?: string; + description?: string; + collections: CollectionInventory[]; + showBarChart: boolean; +}; + +const StatsCollectionsGroup = ({ + heading = "Collections", + description = "Collections configured for your library(ies) in the Palace System.", + collections, + showBarChart, +}: Props) => { + const content = + collections.length === 0 ? ( + No associated collections. + ) : showBarChart ? ( + + ) : ( + + ); + return ( + + {content} + + ); +}; + +export default StatsCollectionsGroup; diff --git a/src/components/StatsCollectionsList.tsx b/src/components/StatsCollectionsList.tsx new file mode 100644 index 000000000..068173aac --- /dev/null +++ b/src/components/StatsCollectionsList.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { CollectionInventory } from "../interfaces"; + +type Props = { + collections: CollectionInventory[]; +}; + +/** Displays statistics about patrons, licenses, and collections from the server, + for a single library or all libraries the admin has access to. */ +const StatsCollectionsList = ({ collections }: Props) => { + const sortedCollections = [...collections].sort((current, next) => + current.name.localeCompare(next.name) + ); + + return ( +
      + {sortedCollections.map(({ id, name }) => ( +
    • {name}
    • + ))} +
    + ); +}; + +export default StatsCollectionsList; diff --git a/src/components/StatsGroup.tsx b/src/components/StatsGroup.tsx new file mode 100644 index 000000000..aaf647c86 --- /dev/null +++ b/src/components/StatsGroup.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +type Props = { + heading: string; + headingAdditionalContent?: React.ReactNode | null; + description: string; + children: React.ReactNode; +}; + +const StatsGroup = ({ + heading, + headingAdditionalContent = null, + description, + children, +}: Props) => ( + <> +
    +

    + {heading} + {headingAdditionalContent} +

    +
    {description}
    +
    + {children} + +); + +export default StatsGroup; diff --git a/src/components/StatsInventoryGroup.tsx b/src/components/StatsInventoryGroup.tsx new file mode 100644 index 000000000..067a3c6d9 --- /dev/null +++ b/src/components/StatsInventoryGroup.tsx @@ -0,0 +1,90 @@ +import React = require("react"); +import { Button } from "library-simplified-reusable-components"; +import StatsGroup from "./StatsGroup"; +import SingleStatListItem from "./SingleStatListItem"; +import { InventoryStatistics } from "../interfaces"; +import InventoryReportRequestModal from "./InventoryReportRequestModal"; +import { inventoryKeyToLabelMap } from "./LibraryStats"; +import { useState } from "react"; + +type Props = { + heading?: string; + description?: string; + inventory: InventoryStatistics; + inventoryReportsEnabled: boolean; + library?: string; +}; + +const StatsInventoryGroup = ({ + heading = "Inventory", + description = "Real-time item inventory.", + inventory, + inventoryReportsEnabled, + library = undefined, +}: Props) => { + const [showReportForm, setShowReportForm] = useState(false); + + return ( + <> + {inventoryReportsEnabled && library && ( + setShowReportForm(false)} + library={library} + /> + )} + setShowReportForm(true)) as any} + content="⬇︎" + title="Request an inventory report" + style={{ + borderRadius: "50%", + marginLeft: "10px", + marginBottom: "0", + marginTop: "-0.7rem", + }} + className="inline small" + disabled={showReportForm} + /> + ) + } + > +
      + + + + + +
    +
    + + ); +}; + +export default StatsInventoryGroup; diff --git a/src/components/StatsPatronGroup.tsx b/src/components/StatsPatronGroup.tsx new file mode 100644 index 000000000..319865b3b --- /dev/null +++ b/src/components/StatsPatronGroup.tsx @@ -0,0 +1,45 @@ +import React = require("react"); +import StatsGroup from "./StatsGroup"; +import SingleStatListItem from "./SingleStatListItem"; + +type Props = { + heading?: string; + description?: string; + total?: number; + withActiveLoan: number; + withActiveLoanOrHold: number; +}; + +const StatsPatronGroup = ({ + heading = "Patrons", + description = "Real-time patron information for the Palace System.", + total = undefined, + withActiveLoan, + withActiveLoanOrHold, +}: Props) => { + return ( + +
      + {total && ( + + )} + + +
    +
    + ); +}; + +export default StatsPatronGroup; diff --git a/src/components/StatsTotalCirculationsGroup.tsx b/src/components/StatsTotalCirculationsGroup.tsx new file mode 100644 index 000000000..ab5fa33a1 --- /dev/null +++ b/src/components/StatsTotalCirculationsGroup.tsx @@ -0,0 +1,36 @@ +import React = require("react"); +import StatsGroup from "./StatsGroup"; +import SingleStatListItem from "./SingleStatListItem"; + +type Props = { + heading?: string; + description?: string; + loans: number; + holds: number; +}; + +const StatsTotalCirculationsGroup = ({ + heading = "Circulation", + description = "Real-time total circulation information of the Palace System.", + loans, + holds, +}: Props) => { + return ( + +
      + + +
    +
    + ); +}; + +export default StatsTotalCirculationsGroup; diff --git a/src/components/__tests__/IndividualAdminEditForm-test.tsx b/src/components/__tests__/IndividualAdminEditForm-test.tsx index d4d303d44..5fc87e6e7 100644 --- a/src/components/__tests__/IndividualAdminEditForm-test.tsx +++ b/src/components/__tests__/IndividualAdminEditForm-test.tsx @@ -8,6 +8,7 @@ import IndividualAdminEditForm from "../IndividualAdminEditForm"; import EditableInput from "../EditableInput"; import Admin from "../../models/Admin"; import { Button, Form, Panel } from "library-simplified-reusable-components"; +import { AdminRoleData, AdminRole } from "../../interfaces"; describe("IndividualAdminEditForm", () => { let wrapper; @@ -18,22 +19,24 @@ describe("IndividualAdminEditForm", () => { }; const allLibraries = [{ short_name: "nypl" }, { short_name: "bpl" }]; - const systemAdmin = [{ role: "system" }]; - const managerAll = [{ role: "manager-all" }]; - const librarianAll = [{ role: "librarian-all" }]; - const nyplManager = [{ role: "manager", library: "nypl" }]; - const bplManager = [{ role: "manager", library: "bpl" }]; - const bothManager = [ + const systemAdmin: AdminRoleData[] = [{ role: "system" }]; + const managerAll: AdminRoleData[] = [{ role: "manager-all" }]; + const librarianAll: AdminRoleData[] = [{ role: "librarian-all" }]; + const nyplManager: AdminRoleData[] = [{ role: "manager", library: "nypl" }]; + const bplManager: AdminRoleData[] = [{ role: "manager", library: "bpl" }]; + const bothManager: AdminRoleData[] = [ { role: "manager", library: "nypl" }, { role: "manager", library: "bpl" }, ]; - const nyplLibrarian = [{ role: "librarian", library: "nypl" }]; - const bplLibrarian = [{ role: "librarian", library: "bpl" }]; - const bothLibrarian = [ + const nyplLibrarian: AdminRoleData[] = [ + { role: "librarian", library: "nypl" }, + ]; + const bplLibrarian: AdminRoleData[] = [{ role: "librarian", library: "bpl" }]; + const bothLibrarian: AdminRoleData[] = [ { role: "librarian", library: "nypl" }, { role: "librarian", library: "bpl" }, ]; - const nyplManagerLibrarianAll = [ + const nyplManagerLibrarianAll: AdminRoleData[] = [ { role: "manager", library: "nypl" }, { role: "librarian-all" }, ]; diff --git a/src/components/__tests__/LibraryStats-test.tsx b/src/components/__tests__/LibraryStats-test.tsx index fcba2e1ee..2b058d1b3 100644 --- a/src/components/__tests__/LibraryStats-test.tsx +++ b/src/components/__tests__/LibraryStats-test.tsx @@ -16,8 +16,14 @@ import { } from "../../../tests/__data__/statisticsApiResponseData"; import { normalizeStatistics } from "../../features/stats/normalizeStatistics"; +import { ContextProviderProps } from "../ContextProvider"; -const AllProviders = componentWithProviders(); +const getAllProviders = ({ isSysAdmin = false } = {}) => { + const contextProviderProps: Partial = isSysAdmin + ? { roles: [{ role: "system" }] } + : {}; + return componentWithProviders({ contextProviderProps }); +}; describe("LibraryStats", () => { // Convert from the API format to our in-app format. @@ -28,7 +34,7 @@ describe("LibraryStats", () => { ); const defaultLibraryStatsTestData = librariesStatsTestDataByKey[testLibraryKey]; - const allLibrariesHeadingText = "All Libraries"; + const allLibrariesHeadingText = "All Authorized Libraries"; const noCollectionsHeadingText = "No associated collections."; const expectStats = ( @@ -51,7 +57,7 @@ describe("LibraryStats", () => { let wrapper; beforeEach(() => { wrapper = mount(, { - wrappingComponent: AllProviders, + wrappingComponent: getAllProviders(), }); }); @@ -96,17 +102,15 @@ describe("LibraryStats", () => { expect(groups.length).to.equal(4); /* Patrons */ - expect(groups.at(0).text()).to.contain("Patrons"); + expect(groups.at(0).text()).to.contain("Current Circulation Activity"); statItems = groups.at(0).find("SingleStatListItem"); - expect(statItems.length).to.equal(3); - expectStats(statItems.at(0).props(), "Total Patrons", 132); - expectStats(statItems.at(1).props(), "Patrons With Active Loans", 21); + expect(statItems.length).to.equal(2); + expectStats(statItems.at(0).props(), "Patrons With Active Loans", 21); expectStats( - statItems.at(2).props(), + statItems.at(1).props(), "Patrons With Active Loans or Holds", 23 ); - expect(groups.at(0).text()).to.contain("132Total Patrons"); expect(groups.at(0).text()).to.contain("21Patrons With Active Loans"); expect(groups.at(0).text()).to.contain( "23Patrons With Active Loans or Holds" @@ -138,6 +142,17 @@ describe("LibraryStats", () => { /* Collections */ expect(groups.at(3).text()).to.contain("Collections"); + }); + + it("shows barchart if user is sysadmin", () => { + wrapper = mount(, { + wrappingComponent: getAllProviders({ isSysAdmin: true }), + }); + + const groups = wrapper.find(".stat-group"); + expect(groups.length).to.equal(4); + + // Chart data will be present because we're a sysadmin. const chart = groups.at(3).find(BarChart); expect(chart.length).to.equal(1); const chartData = chart.props().data; @@ -225,5 +240,41 @@ describe("LibraryStats", () => { }, ]); }); + + it("shows a list of collections instead of barchart, if not sysadmin", () => { + wrapper = mount(, { + wrappingComponent: getAllProviders({ isSysAdmin: false }), + }); + + const groups = wrapper.find(".stat-group"); + expect(groups.length).to.equal(4); + + const collectionGroup = groups.at(3); + + // No chart because we're not a sysadmin. + const chart = collectionGroup.find(BarChart); + expect(chart.length).to.equal(0); + + // But we should still see a list with our collections. + const collectionNames = [ + "New BiblioBoard Test", + "New Bibliotheca Test Collection", + "Palace Bookshelf", + "TEST Baker & Taylor", + "TEST Palace Marketplace", + ]; + + const collectionsList = collectionGroup.find("ul"); + const listItems = collectionsList.find("li"); + expect(collectionsList.length).to.equal(1); + expect(listItems.length).to.equal(collectionNames.length); + + collectionNames.forEach((name: string) => { + expect(collectionsList.text()).to.contain(name); + }); + listItems.forEach((item, index) => { + expect(item.text()).to.contain(collectionNames[index]); + }); + }); }); }); diff --git a/src/index.tsx b/src/index.tsx index 25fa86bf3..90280550d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,42 +17,9 @@ import AccountPage from "./components/AccountPage"; import SetupPage from "./components/SetupPage"; import ManagePatrons from "./components/ManagePatrons"; import TroubleshootingPage from "./components/TroubleshootingPage"; -import { FeatureFlags } from "./interfaces"; +import { ConfigurationSettings } from "./interfaces"; import { defaultFeatureFlags } from "./utils/featureFlags"; -interface ConfigurationSettings { - /** A token generated by the server to prevent Cross-Site Request Forgery. - The token should be included in an 'X-CSRF-Token' header in any non-GET - requests. */ - csrfToken: string; - - /** `showCircEventsDownload` controls whether the dashboard will have an - option to download a CSV of circulation events. This should be false if - circulation events are not available for download. */ - showCircEventsDownload: boolean; - - /** `settingUp` will be true if this is a new circulation manager and the - admin interface has never been used before. The interface will show a page - for configuring admin authentication. The admin will need to set that up - and log in before accessing the rest of the interface. */ - settingUp: boolean; - - /** `email` will be the email address of the currently logged in admin. */ - email?: string; - - /** `roles` contains the logged in admin's roles: system administrator, - or library manager or librarian for one or more libraries. */ - roles?: { - role: string; - library?: string; - }[]; - - tos_link_text?: string; - tos_link_href?: string; - - featureFlags: FeatureFlags; -} - /** The main admin interface application. Create an instance of this class to render the app and set up routing. */ class CirculationAdmin { diff --git a/src/interfaces.ts b/src/interfaces.ts index a10d8c5a7..cb58b9dfa 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,35 @@ /* eslint-disable */ +export interface ConfigurationSettings { + /** A token generated by the server to prevent Cross-Site Request Forgery. + The token should be included in an 'X-CSRF-Token' header in any non-GET + requests. */ + csrfToken: string; + + /** `showCircEventsDownload` controls whether the dashboard will have an + option to download a CSV of circulation events. This should be false if + circulation events are not available for download. */ + showCircEventsDownload: boolean; + + /** `settingUp` will be true if this is a new circulation manager and the + admin interface has never been used before. The interface will show a page + for configuring admin authentication. The admin will need to set that up + and log in before accessing the rest of the interface. */ + settingUp: boolean; + + /** `email` will be the email address of the currently logged in admin. */ + email?: string; + + /** `roles` contains the logged in admin's roles and their associated + libraries, where appropriate. */ + roles?: AdminRoleData[]; + + tos_link_text?: string; + tos_link_href?: string; + + featureFlags: FeatureFlags; +} + export interface FeatureFlags { enableAutoList?: boolean; reportsOnlyForSysadmins?: boolean; @@ -329,9 +359,16 @@ export interface PathFor { (collectionUrl: string, bookUrl: string, tab?: string): string; } +export type AdminRole = + | "system" + | "manager-all" + | "manager" + | "librarian-all" + | "librarian"; + export interface AdminRoleData { library?: string; - role: string; + role: AdminRole; } export interface IndividualAdminData { diff --git a/src/stylesheets/stats.scss b/src/stylesheets/stats.scss index a7aee6008..e705c0bc4 100644 --- a/src/stylesheets/stats.scss +++ b/src/stylesheets/stats.scss @@ -1,7 +1,7 @@ .stats { display: grid; grid-gap: 1rem 1rem; - grid-template-columns: repeat(auto-fit, minmax(17rem,1fr)); + grid-template-columns: repeat(auto-fit, minmax(27rem, 1fr)); padding: 0; .stat-group { @@ -19,20 +19,58 @@ // needed for Rechart ResponsiveContainer to shrink performantly with parent position: absolute; } + + ul.collection-name-list { + display: grid; + grid-gap: 2rem; + grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); + list-style-type: none; + padding: 0; + margin: 0; + overflow-wrap: normal; + } + + .no-collections { + margin: 10px; + font-style: italic; + color: $medium-dark-gray; + } } + .stat-group-description { + margin: 10px; + margin-top: 0px; + margin-bottom: 20px; + text-align: left; + font-style: italic; + font-size: small; + } + + ul { padding: 0; } h3 { + font-weight: bolder; + text-transform: uppercase; margin: 10px; margin-bottom: 2px; } + h4 { + margin: 10px; + margin-top: 0px; + margin-bottom: 20px; + text-align: left; + font-style: italic; + font-size: small; + } + } .stat-grouping-label { text-align: left; + height: 1rem; } svg { @@ -80,7 +118,6 @@ .customTooltipHeading { font-size: larger; font-weight: bold; - //font-size: larger; text-decoration: underline; margin: 0 0; } diff --git a/src/utils/sharedFunctions.ts b/src/utils/sharedFunctions.ts index 0510a791a..1a04185cc 100644 --- a/src/utils/sharedFunctions.ts +++ b/src/utils/sharedFunctions.ts @@ -2,6 +2,8 @@ // so that they stay selected when the form is cleared. Used by LibraryEditForm // and ServiceEditForm. +import numeral = require("numeral"); + export function findDefault(setting) { const defaultOptions = []; setting.options && @@ -68,3 +70,27 @@ export function isEqual(array1: Array, array2: Array): boolean { array1.every((x) => array2.indexOf(x) >= 0) ); } + +/** + * Format number using US conventions, if value provided is a number. + * + * If the value is a non-number string, return that value. + * Otherwise, return the empty string. + * + * @param n - the (possibly numeric) value to format + */ +export const formatNumber = (n: number | string | null): string => { + return !isNaN(Number(n)) + ? Intl.NumberFormat("en-US").format(Number(n)) + : n === String(n) + ? n + : ""; +}; + +/** + * Make a number rounded to it's nearest units (ones, thousands, millions, ...). + * @param n - the number to round (e.g., 1,215) + * @return - the rounded number with its unit (e.g., "1.2k") as string. + */ +export const roundedNumber = (n: number): string => + n ? numeral(n).format("0.[0]a") : "0"; diff --git a/tests/jest/businessRules/roleBasedAccess.test.ts b/tests/jest/businessRules/roleBasedAccess.test.ts new file mode 100644 index 000000000..f2f889db8 --- /dev/null +++ b/tests/jest/businessRules/roleBasedAccess.test.ts @@ -0,0 +1,158 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { componentWithProviders } from "../testUtils/withProviders"; +import { ContextProviderProps } from "../../../src/components/ContextProvider"; +import { ConfigurationSettings, FeatureFlags } from "../../../src/interfaces"; +import { + useMayRequestInventoryReports, + useMayViewCollectionBarChart, +} from "../../../src/businessRules/roleBasedAccess"; + +const setupWrapper = ({ + roles, + featureFlags, +}: Partial) => { + const contextProviderProps: ContextProviderProps = { + featureFlags, + roles, + email: "email", + csrfToken: "token", + }; + return componentWithProviders({ contextProviderProps }); +}; + +describe("Business rules for role-based access", () => { + const libraryMatch = "match"; + const libraryMismatch = "mismatch"; + + describe("controls access to inventory reports", () => { + const testAccess = ( + expectedResult: boolean, + config: Partial + ) => { + const wrapper = setupWrapper(config); + const { result } = renderHook( + () => useMayRequestInventoryReports({ library: libraryMatch }), + { wrapper } + ); + expect(result.current).toBe(expectedResult); + }; + + it("restricts access to only sysadmins, if the restriction feature flag is true", () => { + const featureFlags: FeatureFlags = { reportsOnlyForSysadmins: true }; + + testAccess(true, { roles: [{ role: "system" }], featureFlags }); + + testAccess(false, { roles: [{ role: "manager-all" }], featureFlags }); + testAccess(false, { roles: [{ role: "librarian-all" }], featureFlags }); + + testAccess(false, { + roles: [{ role: "manager", library: libraryMatch }], + featureFlags, + }); + testAccess(false, { + roles: [{ role: "manager", library: libraryMismatch }], + featureFlags, + }); + testAccess(false, { + roles: [{ role: "librarian", library: libraryMatch }], + featureFlags, + }); + testAccess(false, { + roles: [{ role: "librarian", library: libraryMismatch }], + featureFlags, + }); + }); + + it("allows all users, if the restriction feature flag is is false", () => { + const featureFlags: FeatureFlags = { reportsOnlyForSysadmins: false }; + + testAccess(true, { roles: [{ role: "system" }], featureFlags }); + + testAccess(true, { roles: [{ role: "manager-all" }], featureFlags }); + testAccess(true, { roles: [{ role: "librarian-all" }], featureFlags }); + + testAccess(true, { + roles: [{ role: "manager", library: libraryMatch }], + featureFlags, + }); + testAccess(true, { + roles: [{ role: "manager", library: libraryMismatch }], + featureFlags, + }); + testAccess(true, { + roles: [{ role: "librarian", library: libraryMatch }], + featureFlags, + }); + testAccess(true, { + roles: [{ role: "librarian", library: libraryMismatch }], + featureFlags, + }); + }); + + it("allows all users, if the restriction feature flag is not set", () => { + const featureFlags: FeatureFlags = {}; + + testAccess(true, { roles: [{ role: "system" }], featureFlags }); + + testAccess(true, { roles: [{ role: "manager-all" }], featureFlags }); + testAccess(true, { roles: [{ role: "librarian-all" }], featureFlags }); + + testAccess(true, { + roles: [{ role: "manager", library: libraryMatch }], + featureFlags, + }); + testAccess(true, { + roles: [{ role: "manager", library: libraryMismatch }], + featureFlags, + }); + testAccess(true, { + roles: [{ role: "librarian", library: libraryMatch }], + featureFlags, + }); + testAccess(true, { + roles: [{ role: "librarian", library: libraryMismatch }], + featureFlags, + }); + }); + }); + + describe("controls access to the collection statistics barchart", () => { + const testAccess = ( + expectedResult: boolean, + config: Partial + ) => { + const wrapper = setupWrapper(config); + const { result } = renderHook( + () => useMayViewCollectionBarChart({ library: libraryMatch }), + { wrapper } + ); + expect(result.current).toBe(expectedResult); + }; + + it("restricts access to sysadmins", () => { + const featureFlags: FeatureFlags = {}; + + testAccess(true, { roles: [{ role: "system" }], featureFlags }); + + testAccess(false, { roles: [{ role: "manager-all" }], featureFlags }); + testAccess(false, { roles: [{ role: "librarian-all" }], featureFlags }); + + testAccess(false, { + roles: [{ role: "manager", library: libraryMatch }], + featureFlags, + }); + testAccess(false, { + roles: [{ role: "manager", library: libraryMismatch }], + featureFlags, + }); + testAccess(false, { + roles: [{ role: "librarian", library: libraryMatch }], + featureFlags, + }); + testAccess(false, { + roles: [{ role: "librarian", library: libraryMismatch }], + featureFlags, + }); + }); + }); +}); diff --git a/tests/jest/components/Stats.test.tsx b/tests/jest/components/Stats.test.tsx index 0aeaa44cd..340a00f2d 100644 --- a/tests/jest/components/Stats.test.tsx +++ b/tests/jest/components/Stats.test.tsx @@ -1,8 +1,9 @@ import * as React from "react"; import { render } from "@testing-library/react"; import LibraryStats, { - CustomTooltip, + ALL_LIBRARIES_HEADING, } from "../../../src/components/LibraryStats"; +import { CustomTooltip } from "../../../src/components/StatsCollectionsBarChart"; import { componentWithProviders, renderWithProviders, @@ -190,7 +191,7 @@ describe("Dashboard Statistics", () => { // We should show our content without the loading state. assertNotLoadingState({ queryByRole }); - getByRole("heading", { level: 2, name: "Statistics for All Libraries" }); + getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); // We haven't made another call, since the response is cached. expect(fetchMock.calls()).toHaveLength(1); @@ -201,7 +202,7 @@ describe("Dashboard Statistics", () => { // We should show our content immediately, without entering the loading state. assertNotLoadingState({ queryByRole }); - getByRole("heading", { level: 2, name: "Statistics for All Libraries" }); + getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); // We never tried to fetch anything because the result is cached. expect(fetchMock.calls()).toHaveLength(0); @@ -216,10 +217,10 @@ describe("Dashboard Statistics", () => { assertNotLoadingState({ queryByRole }); getByRole("heading", { level: 2, - name: `${sampleLibraryName} Statistics`, + name: `${sampleLibraryName} Dashboard`, }); - getByRole("heading", { level: 3, name: "Patrons" }); - getByText("132"); + 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); @@ -233,9 +234,9 @@ describe("Dashboard Statistics", () => { // We should show our content immediately, without entering the loading state. assertNotLoadingState({ queryByRole }); - getByRole("heading", { level: 2, name: "Statistics for All Libraries" }); - getByRole("heading", { level: 3, name: "Patrons" }); - getByText("145"); + getByRole("heading", { level: 2, name: ALL_LIBRARIES_HEADING }); + getByRole("heading", { level: 3, name: "Current Circulation Activity" }); + getByText("1.6k"); // We never tried to fetch anything because the result is cached. expect(fetchMock.calls()).toHaveLength(0);