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}
- - {renderPatronsGroup(patrons)}
- - {renderCirculationsGroup(patrons)}
-
- {renderInventoryGroup(
- inventory,
- inventoryReportRequestEnabled,
- library
- )}
-
- -
- {renderCollectionsGroup(chartItems)}
-
-
-
- );
-};
-
-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);