diff --git a/package-lock.json b/package-lock.json index 9dd39eef6..40f873ca3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,10 +45,12 @@ "@types/jest": "^29.2.6", "@types/mocha": "^10.0.1", "@types/node": "^18.11.14", + "@types/numeral": "^2.0.5", "@types/prop-types": "^15.7.3", "@types/react": "^16.14.6", "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.16", + "@types/recharts": "^1.8.28", "@types/redux-mock-store": "^1.0.3", "@typescript-eslint/eslint-plugin": "^5.46.0", "@typescript-eslint/parser": "^5.46.0", @@ -2381,6 +2383,21 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", "dev": true }, + "node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "dev": true + }, + "node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "dev": true, + "dependencies": { + "@types/d3-path": "^1" + } + }, "node_modules/@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -2600,6 +2617,12 @@ "optional": true, "peer": true }, + "node_modules/@types/numeral": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/numeral/-/numeral-2.0.5.tgz", + "integrity": "sha512-kH8I7OSSwQu9DS9JYdFWbuvhVzvFRoCPCkGxNwoGgaPeDfEPJlcxNvEOypZhQ3XXHsGbfIuYcxcJxKUfJHnRfw==", + "dev": true + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -2659,6 +2682,16 @@ "redux": "^4.0.0" } }, + "node_modules/@types/recharts": { + "version": "1.8.28", + "resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.28.tgz", + "integrity": "sha512-31D+dVBdVMtBnRMOjfM9210oRsclujQetwDNnCfapy/gF1BruvQkiK9WZ2ZMqDZY2xnDpIV8sWjISBcY+wgkLw==", + "dev": true, + "dependencies": { + "@types/d3-shape": "^1", + "@types/react": "*" + } + }, "node_modules/@types/redux-mock-store": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.3.tgz", @@ -21686,6 +21719,21 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", "dev": true }, + "@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "dev": true + }, + "@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "dev": true, + "requires": { + "@types/d3-path": "^1" + } + }, "@types/debug": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", @@ -21905,6 +21953,12 @@ "optional": true, "peer": true }, + "@types/numeral": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/numeral/-/numeral-2.0.5.tgz", + "integrity": "sha512-kH8I7OSSwQu9DS9JYdFWbuvhVzvFRoCPCkGxNwoGgaPeDfEPJlcxNvEOypZhQ3XXHsGbfIuYcxcJxKUfJHnRfw==", + "dev": true + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -21964,6 +22018,16 @@ "redux": "^4.0.0" } }, + "@types/recharts": { + "version": "1.8.28", + "resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.28.tgz", + "integrity": "sha512-31D+dVBdVMtBnRMOjfM9210oRsclujQetwDNnCfapy/gF1BruvQkiK9WZ2ZMqDZY2xnDpIV8sWjISBcY+wgkLw==", + "dev": true, + "requires": { + "@types/d3-shape": "^1", + "@types/react": "*" + } + }, "@types/redux-mock-store": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.3.tgz", diff --git a/package.json b/package.json index 4704a9e09..cfa78b432 100644 --- a/package.json +++ b/package.json @@ -73,10 +73,12 @@ "@types/jest": "^29.2.6", "@types/mocha": "^10.0.1", "@types/node": "^18.11.14", + "@types/numeral": "^2.0.5", "@types/prop-types": "^15.7.3", "@types/react": "^16.14.6", "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.16", + "@types/recharts": "^1.8.28", "@types/redux-mock-store": "^1.0.3", "@typescript-eslint/eslint-plugin": "^5.46.0", "@typescript-eslint/parser": "^5.46.0", diff --git a/src/components/LibraryStats.tsx b/src/components/LibraryStats.tsx index e68c37d92..a8163341b 100644 --- a/src/components/LibraryStats.tsx +++ b/src/components/LibraryStats.tsx @@ -10,10 +10,10 @@ import { BarChart, ResponsiveContainer, Tooltip, + TooltipProps, XAxis, YAxis, } from "recharts"; -import DefaultTooltipContent from "recharts/lib/component/DefaultTooltipContent"; import SingleStatListItem from "./SingleStatListItem"; export interface LibraryStatsProps { @@ -21,6 +21,16 @@ export interface LibraryStatsProps { 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 = { titles: "Titles", availableTitles: "Available Titles", @@ -33,13 +43,6 @@ const inventoryKeyToLabelMap = { selfHostedTitles: "Self-Hosted Titles", }; -type chartTooltipData = { - dataKey: string; - name: string; - value: number; - color: string; -}; - /** 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) => { @@ -53,7 +56,11 @@ const LibraryStats = (props: LibraryStatsProps) => { } = stats || {}; const chartItems = collections - ?.map(({ name, inventory }) => ({ name, ...inventory })) + ?.map(({ name, inventory, inventoryByMedium }) => ({ + name, + ...inventory, + _by_medium: inventoryByMedium || {}, + })) .sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); return ( @@ -155,11 +162,6 @@ const renderInventoryGroup = (inventory: InventoryStatistics) => { value={inventory.openAccessTitles} tooltip="Number of books for which there are no limits on use." /> - > ); @@ -184,22 +186,13 @@ const renderCollectionsGroup = (chartItems) => { dataKey="name" interval={0} angle={-45} - textAnchor="end" tick={{ dx: -20 }} padding={{ top: 0, bottom: 0 }} height={175} width={125} /> - - } - formatter={formatNumber} - labelStyle={{ - textDecoration: "underline", - fontWeight: "bold", - }} - /> + } /> { }; /* Customize the Rechart tooltip to provide additional information */ -const CustomTooltip = (props) => { - const { active, payload } = props; - if (!active) return null; +export const CustomTooltip = ({ + active, + payload, + label: collectionName, +}: TooltipProps) => { + if (!active) { + return null; + } // Nab inventory data from one of the chart payload objects. - const chartInventory = payload[0].payload; + // 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: chartInventory.titles, + value: chartItem.titles, + perMedium: mediumCountsByProperty["titles"], }, { dataKey: "availableTitles", name: inventoryKeyToLabelMap.availableTitles, - value: chartInventory.availableTitles, + value: chartItem.availableTitles, + perMedium: mediumCountsByProperty["availableTitles"], }, ...payload.filter(({ value }) => value > 0), - ].map((entry) => ({ ...entry, color: aboveTheLineColor })); + ].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(chartInventory) + const belowTheLine = Object.entries(chartItem) .filter(([key]) => !aboveTheLineKeys.includes(key)) - .map(([key, value]) => ({ - dataKey: key, - name: inventoryKeyToLabelMap[key], - value, - color: belowTheLineColor, - })); - const newPayload = [ - ...aboveTheLine, - {}, // blank line - { value: ">>> Additional Information <<<", color: belowTheLineColor }, - ...belowTheLine, - ]; + .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)} + + ) + ); +}; - // We render the default, but with our overridden payload. - return ; +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 => { diff --git a/src/components/__tests__/LibraryStats-test.tsx b/src/components/__tests__/LibraryStats-test.tsx index acf9996ee..672185ef4 100644 --- a/src/components/__tests__/LibraryStats-test.tsx +++ b/src/components/__tests__/LibraryStats-test.tsx @@ -117,19 +117,17 @@ describe("LibraryStats", () => { /* Inventory */ expect(groups.at(2).text()).to.contain("Inventory"); statItems = groups.at(2).find("SingleStatListItem"); - expect(statItems.length).to.equal(6); + expect(statItems.length).to.equal(5); expectStats(statItems.at(0).props(), "Titles", 29119); expectStats(statItems.at(1).props(), "Available Titles", 29092); expectStats(statItems.at(2).props(), "Metered License Titles", 20658); expectStats(statItems.at(3).props(), "Unlimited License Titles", 623); expectStats(statItems.at(4).props(), "Open Access Titles", 7838); - expectStats(statItems.at(5).props(), "Self-Hosted Titles", 145); expect(groups.at(2).text()).to.contain("29.1kTitles"); expect(groups.at(2).text()).to.contain("29.1kAvailable Titles"); expect(groups.at(2).text()).to.contain("20.7kMetered License Titles"); expect(groups.at(2).text()).to.contain("623Unlimited License Titles"); expect(groups.at(2).text()).to.contain("7.8kOpen Access Titles"); - expect(groups.at(2).text()).to.contain("145Self-Hosted Titles"); /* Collections */ expect(groups.at(3).text()).to.contain("Collections"); @@ -150,6 +148,7 @@ describe("LibraryStats", () => { meteredLicenseTitles: 13306, meteredLicensesOwned: 13306, meteredLicensesAvailable: 13306, + _by_medium: {}, }); expect(chart.props().data).to.deep.equal([ { @@ -163,6 +162,7 @@ describe("LibraryStats", () => { meteredLicenseTitles: 13306, meteredLicensesOwned: 13306, meteredLicensesAvailable: 13306, + _by_medium: {}, }, { name: "New Bibliotheca Test Collection", @@ -175,6 +175,7 @@ describe("LibraryStats", () => { meteredLicenseTitles: 76, meteredLicensesOwned: 85, meteredLicensesAvailable: 72, + _by_medium: {}, }, { name: "Palace Bookshelf", @@ -187,6 +188,7 @@ describe("LibraryStats", () => { meteredLicenseTitles: 0, meteredLicensesOwned: 0, meteredLicensesAvailable: 0, + _by_medium: {}, }, { name: "TEST Baker & Taylor", @@ -199,6 +201,7 @@ describe("LibraryStats", () => { meteredLicenseTitles: 146, meteredLicensesOwned: 147, meteredLicensesAvailable: 135, + _by_medium: {}, }, { name: "TEST Palace Marketplace", @@ -211,6 +214,7 @@ describe("LibraryStats", () => { meteredLicenseTitles: 7753, meteredLicensesOwned: 305725, meteredLicensesAvailable: 75337, + _by_medium: {}, }, ]); }); diff --git a/src/interfaces.ts b/src/interfaces.ts index 3c30e4e25..c072d1fac 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -136,6 +136,10 @@ export interface InventoryStatistics { meteredLicensesAvailable: number; } +export interface InventoryByMedium { + [medium: string]: InventoryStatistics; +} + export interface PatronStatistics { total: number; withActiveLoan: number; @@ -149,6 +153,7 @@ export interface LibraryStatistics { name: string; patronStatistics: PatronStatistics; inventorySummary: InventoryStatistics; + inventoryByMedium?: InventoryByMedium; collectionIds: number[]; collections?: CollectionInventory[]; } @@ -157,6 +162,7 @@ export interface CollectionInventory { id: number; name: string; inventory: InventoryStatistics; + inventoryByMedium?: InventoryByMedium; } export interface StatisticsData { @@ -170,6 +176,7 @@ export interface StatisticsData { [key: string]: LibraryStatistics; }; inventorySummary: InventoryStatistics; + inventoryByMedium?: InventoryByMedium; patronSummary: PatronStatistics; summaryStatistics?: LibraryStatistics; } diff --git a/src/stylesheets/stats.scss b/src/stylesheets/stats.scss index 7c6e9f830..a7aee6008 100644 --- a/src/stylesheets/stats.scss +++ b/src/stylesheets/stats.scss @@ -2,7 +2,7 @@ display: grid; grid-gap: 1rem 1rem; grid-template-columns: repeat(auto-fit, minmax(17rem,1fr)); - padding: 0px; + padding: 0; .stat-group { display: grid; @@ -22,7 +22,7 @@ } ul { - padding: 0px; + padding: 0; } h3 { @@ -60,9 +60,45 @@ display: flex; align-items: center; text-align: left; - margin: 10px 0px; + margin: 10px 0; padding: 10px; } -} + .customTooltip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgb(0 0 0 / 40%); + padding: 1px; + text-align: left; + border-radius: 12px; + + .customTooltipDetail { + margin: 13px 13px; + padding: 10px; + } + .customTooltipHeading { + font-size: larger; + font-weight: bold; + //font-size: larger; + text-decoration: underline; + margin: 0 0; + } + + .customTooltipItem { + margin: 3px 0; + + span { + vertical-align: middle; + line-height: 100%; + } + + .customTooltipMediumBreakdown { + font-size: smaller; + margin: 0 .5em; + } + } + } + + +} diff --git a/tests/jest/components/Stats.test.tsx b/tests/jest/components/Stats.test.tsx new file mode 100644 index 000000000..605f81106 --- /dev/null +++ b/tests/jest/components/Stats.test.tsx @@ -0,0 +1,231 @@ +import * as React from "react"; +import { render } from "@testing-library/react"; +import { CustomTooltip } from "../../../src/components/LibraryStats"; + +describe("Dashboard Statistics", () => { + // NB: This adds test to the already existing tests in: + // - `src/components/__tests__/Stats-test.tsx`. + // - `src/components/__tests__/LibraryStats-test.tsx`. + // - `src/components/__tests__/SingleStatListItem-test.tsx`. + // + // Those tests should eventually be migrated here and + // adapted to the Jest/React Testing Library paradigm. + + describe("charting - custom tooltip", () => { + const defaultLabel = "Collection X"; + const summaryInventory = { + availableTitles: 7953, + licensedTitles: 7974, + meteredLicenseTitles: 7974, + meteredLicensesAvailable: 75446, + meteredLicensesOwned: 301541, + openAccessTitles: 0, + titles: 7974, + unlimitedLicenseTitles: 0, + }; + const perMediumInventory = { + Audio: { + availableTitles: 148, + licensedTitles: 165, + meteredLicenseTitles: 165, + meteredLicensesAvailable: 221, + meteredLicensesOwned: 392, + openAccessTitles: 0, + titles: 165, + unlimitedLicenseTitles: 0, + }, + Book: { + availableTitles: 7805, + licensedTitles: 7809, + meteredLicenseTitles: 7809, + meteredLicensesAvailable: 75225, + meteredLicensesOwned: 301149, + openAccessTitles: 0, + titles: 7809, + unlimitedLicenseTitles: 0, + }, + }; + const defaultChartItemWithoutPerMediumInventory = { + name: defaultLabel, + ...summaryInventory, + }; + const defaultChartItemWithPerMediumInventory = { + ...defaultChartItemWithoutPerMediumInventory, + _by_medium: perMediumInventory, + }; + const defaultPayload = [ + { + fill: "#606060", + dataKey: "meteredLicenseTitles", + name: "Metered License Titles", + color: "#606060", + value: 7974, + }, + { + fill: "#404040", + dataKey: "unlimitedLicenseTitles", + name: "Unlimited License Titles", + color: "#404040", + value: 0, + }, + { + fill: "#202020", + dataKey: "openAccessTitles", + name: "Open Access Titles", + color: "#202020", + value: 0, + }, + ]; + + const populateTooltipProps = ({ + active = true, + label = defaultLabel, + payload = [], + chartItem = undefined, + }) => { + const constructedChartItem = !chartItem + ? chartItem + : { + ...chartItem, + name: label, + }; + const constructedPayload = payload.map((entry) => ({ + ...entry, + payload: constructedChartItem, + })); + return { + active, + label, + payload: constructedPayload, + }; + }; + + /** + * Helper function to test passing tests for a tooltip + * + * @param tooltipProps - passed to the component + * @param expectedInventoryItemText - the expected inventory item text content + */ + const expectPassingTestsForActiveTooltip = ({ + tooltipProps, + expectedInventoryItemText, + }) => { + const { container, getByRole } = render( + + ); + const tooltipContent = container.querySelector(".customTooltip"); + + const detail = tooltipContent.querySelector(".customTooltipDetail"); + const detailChildren = detail.children; + const heading = getByRole("heading", { level: 1, name: "Collection X" }); + const items = tooltipContent.querySelectorAll("p.customTooltipItem"); + const divider = detail.querySelector("hr"); + + expect(heading).toHaveTextContent("Collection X"); + + // Eight (8) metrics in the following order. + expect(items).toHaveLength(8); + // The expected inventory item labels array should be the same length. + expect(expectedInventoryItemText).toHaveLength(items.length); + // And the items should contain at least the expected text. + Array.from(items).forEach((item, index) => { + expect(item).toHaveTextContent(expectedInventoryItemText[index]); + }); + + // The heading should be at the top and the divider (`hr`) + // should be between the third and fourth statistics. + expect(detailChildren).toHaveLength(10); + expect(heading).toEqual(detailChildren[0]); + expect(items[0]).toEqual(detailChildren[1]); + expect(items[2]).toEqual(detailChildren[3]); + expect(divider).toEqual(detailChildren[4]); + expect(items[3]).toEqual(detailChildren[5]); + expect(items[7]).toEqual(detailChildren[9]); + }; + + it("should not render when active is false", () => { + // Recharts sticks some extra props + const tooltipProps = populateTooltipProps({ + active: false, + chartItem: defaultChartItemWithPerMediumInventory, + payload: defaultPayload, + }); + + const { container, getByRole } = render( + + ); + const tooltipContent = container.querySelectorAll(".customTooltip"); + + expect(tooltipContent).toHaveLength(0); + }); + it("should render when active is true", () => { + const tooltipProps = populateTooltipProps({ + active: true, + chartItem: defaultChartItemWithoutPerMediumInventory, + payload: defaultPayload, + }); + + const expectedInventoryItemText = [ + "Titles:", + "Available Titles:", + "Metered License Titles:", + "Licensed Titles:", + "Metered Licenses Available:", + "Metered Licenses Owned:", + "Open Access Titles:", + "Unlimited License Titles:", + ]; + + expectPassingTestsForActiveTooltip({ + tooltipProps, + expectedInventoryItemText, + }); + }); + it("should render without per-medium inventory", () => { + const tooltipProps = populateTooltipProps({ + active: true, + chartItem: defaultChartItemWithoutPerMediumInventory, + payload: defaultPayload, + }); + + const expectedInventoryItemText = [ + "Titles: 7,974", + "Available Titles: 7,953", + "Metered License Titles: 7,974", + "Licensed Titles: 7,974", + "Metered Licenses Available: 75,446", + "Metered Licenses Owned: 301,541", + "Open Access Titles: 0", + "Unlimited License Titles: 0", + ]; + + expectPassingTestsForActiveTooltip({ + tooltipProps, + expectedInventoryItemText, + }); + }); + it("should render additional detail with per-medium inventory", () => { + const tooltipProps = populateTooltipProps({ + active: true, + chartItem: defaultChartItemWithPerMediumInventory, + payload: defaultPayload, + }); + + const expectedInventoryItemText = [ + "Titles: 7,974 (Audio: 165, Book: 7,809)", + "Available Titles: 7,953 (Audio: 148, Book: 7,805)", + "Metered License Titles: 7,974 (Audio: 165, Book: 7,809)", + "Licensed Titles: 7,974 (Audio: 165, Book: 7,809)", + "Metered Licenses Available: 75,446 (Audio: 221, Book: 75,225)", + "Metered Licenses Owned: 301,541 (Audio: 392, Book: 301,149)", + "Open Access Titles: 0", + "Unlimited License Titles: 0", + ]; + + expectPassingTestsForActiveTooltip({ + tooltipProps, + expectedInventoryItemText, + }); + }); + }); +});
+ {!!name && {name}:} + {formatNumber(value)} + {perMediumBreakdown(perMedium)} +