diff --git a/package-lock.json b/package-lock.json index 4f7003652..959c97a01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "react-dom": "^16.8.6", "react-redux": "^7.1.0", "react-router": "^3.2.0", - "recharts": "^1.0.1", + "recharts": "^1.8.6", "redux": "^4.0.1", "redux-thunk": "^2.3.0", "request": "^2.85.0", @@ -49,6 +49,7 @@ "@types/react": "^16.14.6", "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.16", + "@types/redux-mock-store": "^1.0.3", "@typescript-eslint/eslint-plugin": "^5.46.0", "@typescript-eslint/parser": "^5.46.0", "chai": "4.2.0", @@ -80,6 +81,7 @@ "node-sass": "^8.0.0", "prettier": "2.1.2", "react-axe": "^3.3.0", + "redux-mock-store": "^1.5.4", "sass-lint": "^1.13.1", "sass-loader": "^13.2.0", "selenium-standalone": "^6.16.0", @@ -2141,6 +2143,15 @@ "redux": "^4.0.0" } }, + "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", + "integrity": "sha512-Wqe3tJa6x9MxMN4DJnMfZoBRBRak1XTPklqj4qkVm5VBpZnC8PSADf4kLuFQ9NAdHaowfWoEeUMz7NWc2GMtnA==", + "dev": true, + "dependencies": { + "redux": "^4.0.5" + } + }, "node_modules/@types/responselike": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", @@ -10567,6 +10578,12 @@ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "dev": true }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -14836,6 +14853,15 @@ "resolved": "https://registry.npmjs.org/redux-localstorage/-/redux-localstorage-0.4.1.tgz", "integrity": "sha512-dUha0YoH+BSZ2q15pakB+JWeqiuXUf3Ir4rObOpNrZ96HEdciGAjkL10k3KGdLI7qvQw/c096asw/SQ6TPjU/A==" }, + "node_modules/redux-mock-store": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz", + "integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==", + "dev": true, + "dependencies": { + "lodash.isplainobject": "^4.0.6" + } + }, "node_modules/redux-thunk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", @@ -20810,6 +20836,15 @@ "redux": "^4.0.0" } }, + "@types/redux-mock-store": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.3.tgz", + "integrity": "sha512-Wqe3tJa6x9MxMN4DJnMfZoBRBRak1XTPklqj4qkVm5VBpZnC8PSADf4kLuFQ9NAdHaowfWoEeUMz7NWc2GMtnA==", + "dev": true, + "requires": { + "redux": "^4.0.5" + } + }, "@types/responselike": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", @@ -27287,6 +27322,12 @@ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "dev": true }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, "lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -30592,6 +30633,15 @@ "resolved": "https://registry.npmjs.org/redux-localstorage/-/redux-localstorage-0.4.1.tgz", "integrity": "sha512-dUha0YoH+BSZ2q15pakB+JWeqiuXUf3Ir4rObOpNrZ96HEdciGAjkL10k3KGdLI7qvQw/c096asw/SQ6TPjU/A==" }, + "redux-mock-store": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz", + "integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==", + "dev": true, + "requires": { + "lodash.isplainobject": "^4.0.6" + } + }, "redux-thunk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", diff --git a/package.json b/package.json index c216f976a..dd2386182 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test-js-list": "mocha --require lib/testHelper.js lib/__tests__/*.js lib/**/__tests__/*.js lib/**/**/__tests__/*.js --reporter ./testReporter.js", "test-ts": "npm run lint && mocha -r ts-node/register --require src/testHelper.ts src/__tests__/*.ts* src/**/__tests__/*.ts* src/**/**/__tests__/*.ts*", "test-file-ts": "npm run lint && mocha -r ts-node/register --require src/testHelper.ts", + "test-file-ts-nolint": "mocha -r ts-node/register --require src/testHelper.ts", "test": "npm run test-ts && npm run test-jest", "test-file": "npm run test-file-ts", "test-browser": "npm run test-chrome && npm run test-firefox", @@ -57,7 +58,7 @@ "react-dom": "^16.8.6", "react-redux": "^7.1.0", "react-router": "^3.2.0", - "recharts": "^1.0.1", + "recharts": "^1.8.6", "redux": "^4.0.1", "redux-thunk": "^2.3.0", "request": "^2.85.0", @@ -76,6 +77,7 @@ "@types/react": "^16.14.6", "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.16", + "@types/redux-mock-store": "^1.0.3", "@typescript-eslint/eslint-plugin": "^5.46.0", "@typescript-eslint/parser": "^5.46.0", "chai": "4.2.0", @@ -107,6 +109,7 @@ "node-sass": "^8.0.0", "prettier": "2.1.2", "react-axe": "^3.3.0", + "redux-mock-store": "^1.5.4", "sass-lint": "^1.13.1", "sass-loader": "^13.2.0", "selenium-standalone": "^6.16.0", diff --git a/src/__tests__/actions-test.ts b/src/__tests__/actions-test.ts index b6f6fc1d9..9e0d5e2cb 100644 --- a/src/__tests__/actions-test.ts +++ b/src/__tests__/actions-test.ts @@ -544,23 +544,23 @@ describe("actions", () => { describe("fetchStats", () => { it("dispatches request, load, and success", async () => { const dispatch = stub(); - const statsData = "stats"; + const statisticsApiResponseData = "stats"; fetcher.testData = { ok: true, status: 200, json: () => new Promise((resolve, reject) => { - resolve(statsData); + resolve(statisticsApiResponseData); }), }; fetcher.resolve = true; - const data = await actions.fetchStats()(dispatch); + const data = await actions.fetchStatistics()(dispatch); expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal(ActionCreator.STATS_REQUEST); expect(dispatch.args[1][0].type).to.equal(ActionCreator.STATS_SUCCESS); expect(dispatch.args[2][0].type).to.equal(ActionCreator.STATS_LOAD); - expect(data).to.deep.equal(statsData); + expect(data).to.deep.equal(statisticsApiResponseData); }); }); diff --git a/src/actions.ts b/src/actions.ts index 9aec996a6..6b0e1130b 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -5,7 +5,6 @@ import { GenreTree, ClassificationData, CirculationEventData, - StatsData, LibrariesData, CollectionsData, IndividualAdminsData, @@ -31,6 +30,7 @@ import { DiagnosticsData, FeatureFlags, SitewideAnnouncementsData, + StatisticsData, } from "./interfaces"; import { CollectionData } from "@thepalaceproject/web-opds-client/lib/interfaces"; import DataFetcher from "@thepalaceproject/web-opds-client/lib/DataFetcher"; @@ -463,9 +463,9 @@ export default class ActionCreator extends BaseActionCreator { ).bind(this); } - fetchStats() { + fetchStatistics() { const url = "/admin/stats"; - return this.fetchJSON(ActionCreator.STATS, url).bind(this); + return this.fetchJSON(ActionCreator.STATS, url).bind(this); } fetchLibraries() { diff --git a/src/components/DashboardPage.tsx b/src/components/DashboardPage.tsx index 2aeee6c76..7293e617f 100644 --- a/src/components/DashboardPage.tsx +++ b/src/components/DashboardPage.tsx @@ -1,11 +1,11 @@ import * as React from "react"; import { Store } from "redux"; +import { RootState } from "../store"; import * as PropTypes from "prop-types"; import Header from "./Header"; import Footer from "./Footer"; import Stats from "./Stats"; import CirculationEvents from "./CirculationEvents"; -import { RootState } from "../store"; import title from "../utils/title"; export interface DashboardPageProps extends React.Props { @@ -27,7 +27,7 @@ export default class DashboardPage extends React.Component { editorStore: PropTypes.object.isRequired as React.Validator, }; - static childContextTypes: React.ValidationMap = { + static childContextTypes: React.ValidationMap = { library: PropTypes.func, }; @@ -43,7 +43,7 @@ export default class DashboardPage extends React.Component {
- + { - render(): JSX.Element { - const collectionCounts = - this.props.stats && - this.props.stats.collections && - Object.keys(this.props.stats.collections) - .map((collection) => { - const data = this.props.stats.collections[collection]; - return { - label: collection, - // The "Titles" key is displayed in the default recharts tooltip. - "Open Access Titles": data.open_access_titles, - "Licensed Titles": data.licensed_titles, - }; - }) - .filter((collection) => { - const open_access = collection["Open Access Titles"]; - const licensed = collection["Licensed Titles"]; - return (open_access && open_access > 0) || (licensed && licensed > 0); - }); - - return ( -
- {this.props.library ? ( -

- {this.props.library.name || this.props.library.short_name}{" "} - Statistics -

- ) : ( -

Statistics for All Libraries

- )} - {this.props.stats && ( -
    - {this.props.stats.patrons.total > 0 && ( -
  • -

    - Patrons -

    -
      -
    • - - {this.formatNumber(this.props.stats.patrons.total)} - - Total Patrons -
    • -
    • - - {this.formatNumber( - this.props.stats.patrons.with_active_loans - )} - - - Patrons With Active Loans - -
    • -
    • - - {this.formatNumber( - this.props.stats.patrons.with_active_loans_or_holds - )} - - - Patrons With Active Loans or Holds - -
    • -
    -
  • - )} - {this.props.stats.patrons.total > 0 && ( -
  • -

    - Circulation -

    -
      -
    • -
      - {this.formatNumber(this.props.stats.patrons.loans)} -
      -
      Active Loans
      -
    • -
    • -
      - {this.formatNumber(this.props.stats.patrons.holds)} -
      -
      Active Holds
      -
    • -
    -
  • - )} -
  • -

    - Inventory -

    -
      -
    • -
      - {this.formatNumber(this.props.stats.inventory.titles)} -
      -
      Titles
      -
    • - {this.props.stats.inventory.licenses > 0 && ( -
    • -
      - {this.formatNumber(this.props.stats.inventory.licenses)} -
      -
      Total Licenses
      -
    • - )} - {this.props.stats.inventory.licenses > 0 && ( -
    • -
      - {this.formatNumber( - this.props.stats.inventory.available_licenses - )} -
      -
      Available Licenses
      -
    • - )} -
    -
  • - {collectionCounts.length > 0 && ( -
  • -

    - Collections -

    - - - - - - - -
  • - )} -
- )} -
- ); - } - - formatNumber(n) { - return n ? numeral(n).format("0.[0]a") : 0; - } -} + for a single library or all libraries the admin has access to. */ +const LibraryStats = (props: LibraryStatsProps) => { + const { stats, library } = props; + const { + name: libraryName, + key: libraryKey, + collections, + inventorySummary: inventory, + patronStatistics: patrons, + } = stats || {}; + + const chartItems = collections + ?.map(({ name, inventory }) => ({ name, ...inventory })) + .sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); + + return ( +
+ {library ? ( +

{libraryName || libraryKey} Statistics

+ ) : ( +

Statistics for All Libraries

+ )} +
    +
  • {renderPatronsGroup(patrons)}
  • +
  • {renderCirculationsGroup(patrons)}
  • +
  • {renderInventoryGroup(inventory)}
  • +
  • + {renderCollectionsGroup(chartItems)} +
  • +
+
+ ); +}; + +const renderPatronsGroup = (patrons: PatronStatistics) => { + return ( + <> +

+ Patrons +

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

+ Circulation +

+
    + + +
+ + ); +}; + +const renderInventoryGroup = (inventory: InventoryStatistics) => { + return ( + <> +

+ Inventory +

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

No associated collections.

+ ) : ( + <> +

+ Collections +

+ + + + + + } + 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; + + // Nab inventory data from one of the chart payload objects. + const chartInventory = payload[0].payload; + const aboveTheLineColor = "#030303"; + const belowTheLineColor = "#A0A0A0"; + const aboveTheLine: chartTooltipData[] = [ + { + dataKey: "titles", + name: inventoryKeyToLabelMap.titles, + value: chartInventory.titles, + }, + { + dataKey: "availableTitles", + name: inventoryKeyToLabelMap.availableTitles, + value: chartInventory.availableTitles, + }, + ...payload.filter(({ value }) => value > 0), + ].map((entry) => ({ ...entry, color: aboveTheLineColor })); + const aboveTheLineKeys = [ + "name", + ...aboveTheLine.map(({ dataKey }) => dataKey), + ]; + const belowTheLine = Object.entries(chartInventory) + .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, + ]; + + // We render the default, but with our overridden payload. + return ; +}; + +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 new file mode 100644 index 000000000..a43a262b7 --- /dev/null +++ b/src/components/SingleStatListItem.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import * as numeral from "numeral"; +import { formatNumber, humanNumber } from "./LibraryStats"; + +export interface SingleStatListItemProps { + label: string; + value: number; + tooltip?: string; +} + +const SingleStatListItem = (props: SingleStatListItemProps) => { + const baseStat = ( + <> + {humanNumber(props.value)} + {props.label} + + ); + return ( +
  • + {!props.tooltip ? ( + baseStat + ) : ( + + {baseStat} + + )} +
  • + ); +}; + +export default SingleStatListItem; diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index 3b7797f76..6201fe71a 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -1,118 +1,99 @@ import * as React from "react"; -import { Store } from "redux"; -import { connect } from "react-redux"; +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; import ActionCreator from "../actions"; +import { stateSelector as statsStateSelector } from "../reducers/stats"; import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; -import { StatsData, LibrariesData, LibraryData } from "../interfaces"; -import { RootState } from "../store"; +import { LibraryStatistics, StatisticsData } from "../interfaces"; import LoadingIndicator from "@thepalaceproject/web-opds-client/lib/components/LoadingIndicator"; import ErrorMessage from "./ErrorMessage"; import LibraryStats from "./LibraryStats"; -export interface StatsStateProps { - stats?: StatsData; - libraries?: LibraryData[]; +export interface StatsLocalState { + data?: StatisticsData; fetchError?: FetchErrorData; isLoaded?: boolean; } -export interface StatsDispatchProps { - fetchStats?: () => Promise; - fetchLibraries?: () => Promise; -} - -export interface StatsOwnProps { - store?: Store; +export interface StatsProps { library?: string; } -export interface StatsProps - extends StatsStateProps, - StatsDispatchProps, - StatsOwnProps {} - -/** Displays statistics about patrons, licenses, and vendors from the server. */ -export class Stats extends React.Component { - render(): JSX.Element { - let libraryData: LibraryData | null = null; - for (const library of this.props.libraries || []) { - if (this.props.library && library.short_name === this.props.library) { - libraryData = library; - } - } +/** + * Displays statistics about patrons, licenses, and collections from the server. + * If a library prop is provided, then statistics for that library will be displayed. + * Otherwise, statistics for all authorized libraries will be displayed. + * @param {StatsProps} props + * @param {string} props.library - key (short name) of a library. + * */ +export const Stats = (props: StatsProps) => { + const { library: targetLibraryKey } = props; + const { data: statisticsData, fetchError, isLoaded } = useStatistics(); - if ( - !libraryData && - this.props.libraries && - this.props.libraries.length === 1 - ) { - libraryData = this.props.libraries[0]; - } + const summaryStatistics = statisticsData?.summaryStatistics; + const targetLibraryData = statisticsData?.libraries?.find( + (library) => library.key === targetLibraryKey + ); - const libraryStats = - this.props.isLoaded && - this.props.stats && - libraryData && - this.props.stats[libraryData.short_name]; - const totalStats = - this.props.isLoaded && - this.props.libraries && - this.props.libraries.length > 1 && - this.props.stats && - this.props.stats["total"]; + return ( + <> + {targetLibraryData && ( + + )} + {!targetLibraryData && summaryStatistics && ( + + )} + {fetchError && } + {!isLoaded && } + + ); +}; - return ( - <> - {libraryStats && ( - - )} - {totalStats && } - {this.props.fetchError && ( - - )} - - {!this.props.isLoaded && } - - ); +/** + * Convert the fetched data from the sparse API format. + * - Adds a list of collections to each library, one for each collectionId. + * - Adds summary statistics in the form of a LibraryStatistics object. + * @param {StatisticsData} statistics - Statistics data fetched via API. + */ +export const normalizeStatistics = (statistics): StatisticsData => { + if (!statistics) { + return statistics; } - UNSAFE_componentWillMount() { - if (this.props.fetchStats) { - this.props.fetchStats(); - } - if (this.props.fetchLibraries()) { - this.props.fetchLibraries(); - } - } -} + const collectionsById = statistics.collections.reduce( + (map, collection) => ({ ...map, [collection.id]: collection }), + {} + ); + const libraries = statistics.libraries.map((l) => ({ + ...l, + collections: l.collectionIds.map((id) => collectionsById[id]), + })); + const collectionIds = statistics.collections.map((c) => c.id); -function mapStateToProps(state, ownProps) { - return { - stats: state.editor.stats.data, - libraries: - state.editor.libraries && - state.editor.libraries.data && - state.editor.libraries.data.libraries, - fetchError: state.editor.stats.fetchError, - isLoaded: state.editor.stats.isLoaded, + const summaryStatistics: LibraryStatistics = { + key: "_summary_", + name: "Summary Statistics", + patronStatistics: statistics.patronSummary, + inventorySummary: statistics.inventorySummary, + collectionIds, + collections: statistics.collections, }; -} -function mapDispatchToProps(dispatch) { - const actions = new ActionCreator(); - return { - fetchStats: () => dispatch(actions.fetchStats()), - fetchLibraries: () => dispatch(actions.fetchLibraries()), - }; -} + return { ...statistics, libraries, summaryStatistics }; +}; + +const useStatistics = (): StatsLocalState => { + const data = useSelector(statsStateSelector.data); + const isLoaded = useSelector(statsStateSelector.isLoaded); + const fetchError = useSelector(statsStateSelector.fetchError); + + const dispatch = useDispatch(); + useEffect(() => { + const actions = new ActionCreator(); + dispatch(actions.fetchStatistics()); + }, []); -const ConnectedStats = connect< - StatsStateProps, - StatsDispatchProps, - StatsOwnProps ->( - mapStateToProps, - mapDispatchToProps -)(Stats); + return { data: normalizeStatistics(data), fetchError, isLoaded }; +}; -export default ConnectedStats; +export default Stats; diff --git a/src/components/__tests__/DashboardPage-test.tsx b/src/components/__tests__/DashboardPage-test.tsx index 15ec3e07d..8beab4fa9 100644 --- a/src/components/__tests__/DashboardPage-test.tsx +++ b/src/components/__tests__/DashboardPage-test.tsx @@ -40,12 +40,10 @@ describe("DashboardPage", () => { it("shows Stats", () => { let stats = wrapper.find(Stats); - expect(stats.prop("store")).to.equal(store); expect(stats.prop("library")).to.be.undefined; wrapper.setProps({ params: { library: "NYPL" } }); stats = wrapper.find(Stats); - expect(stats.prop("store")).to.equal(store); expect(stats.prop("library")).to.equal("NYPL"); }); diff --git a/src/components/__tests__/LibraryStats-test.tsx b/src/components/__tests__/LibraryStats-test.tsx index 58a2492dd..acf9996ee 100644 --- a/src/components/__tests__/LibraryStats-test.tsx +++ b/src/components/__tests__/LibraryStats-test.tsx @@ -1,166 +1,218 @@ import { expect } from "chai"; -import { stub } from "sinon"; import * as React from "react"; -import { shallow } from "enzyme"; +import { mount } from "enzyme"; +import { normalizeStatistics } from "../Stats"; import LibraryStats from "../LibraryStats"; -import { LibraryStatsData } from "../../interfaces"; import { BarChart } from "recharts"; +import { + statisticsApiResponseData, + testLibraryKey, + noCollectionsLibraryKey, + noInventoryLibraryKey, + noPatronsLibraryKey, +} from "../../../tests/__data__/statisticsApiResponseData"; describe("LibraryStats", () => { - const statsData: LibraryStatsData = { - patrons: { - total: 3456, - with_active_loans: 55, - with_active_loans_or_holds: 1234, - loans: 100, - holds: 2000, - }, - inventory: { - titles: 54321, - licenses: 123456, - available_licenses: 100000, - }, - collections: { - Overdrive: { - licensed_titles: 490, - open_access_titles: 10, - licenses: 350, - available_licenses: 100, - }, - Bibliotheca: { - licensed_titles: 400, - open_access_titles: 0, - licenses: 300, - available_licenses: 170, - }, - "Axis 360": { - licensed_titles: 300, - open_access_titles: 0, - licenses: 280, - available_licenses: 260, - }, - "Open Bookshelf": { - licensed_titles: 0, - open_access_titles: 1200, - licenses: 0, - available_licenses: 0, - }, - }, + // Convert from the API format to our in-app format. + const statisticsData = normalizeStatistics(statisticsApiResponseData); + const librariesStatsTestDataByKey = statisticsData.libraries.reduce( + (map, library) => ({ ...map, [library.key]: library }), + {} + ); + const defaultLibraryStatsTestData = + librariesStatsTestDataByKey[testLibraryKey]; + const allLibrariesHeadingText = "All Libraries"; + const noCollectionsHeadingText = "No associated collections."; + + const expectStats = ( + { label, value }, + expected_label: string, + expected_value + ) => { + expect(label).to.equal(expected_label); + expect(value).to.equal(expected_value); }; - const libraryData = { - name: "Brooklyn Public Library", - short_name: "BPL", + const expectAllGroups = (groups) => { + expect(groups.length).to.equal(4); + expect(groups.at(0).text()).to.contain("Patrons"); + expect(groups.at(1).text()).to.contain("Circulation"); + expect(groups.at(2).text()).to.contain("Inventory"); }; describe("rendering", () => { let wrapper; - beforeEach(() => { - wrapper = shallow(); + wrapper = mount(); }); it("shows 'all libraries' header when there is no library", () => { const header = wrapper.find("h2"); - expect(header.text()).to.contain("All Libraries"); + expect(header.text()).to.contain(allLibrariesHeadingText); }); it("shows library header", () => { - wrapper.setProps({ library: libraryData }); + wrapper.setProps({ library: defaultLibraryStatsTestData.key }); const header = wrapper.find("h2"); - expect(header.text()).to.contain("Brooklyn Public Library"); - expect(header.text()).not.to.contain("All Libraries"); + expect(header.text()).to.contain(defaultLibraryStatsTestData.name); + expect(header.text()).not.to.contain(allLibrariesHeadingText); + }); + + it("show patrons and circulation groups, even when no patrons", () => { + const noPatrons = librariesStatsTestDataByKey[noPatronsLibraryKey]; + wrapper.setProps({ stats: noPatrons }); + const groups = wrapper.find(".stat-group"); + expectAllGroups(groups); + }); + + it("shows inventory group, even when there is no inventory", () => { + const noInventory = librariesStatsTestDataByKey[noInventoryLibraryKey]; + wrapper.setProps({ stats: noInventory }); + const groups = wrapper.find(".stat-group"); + expectAllGroups(groups); + }); + + it("shows appropriate message when there are no collections", () => { + const noCollections = + librariesStatsTestDataByKey[noCollectionsLibraryKey]; + wrapper.setProps({ stats: noCollections }); + const groups = wrapper.find(".stat-group"); + expectAllGroups(groups); + expect(groups.at(3).text()).to.contain(noCollectionsHeadingText); }); it("shows stats data", () => { const groups = wrapper.find(".stat-group"); + let statItems; expect(groups.length).to.equal(4); + /* Patrons */ expect(groups.at(0).text()).to.contain("Patrons"); - expect(groups.at(0).text()).to.contain("3.5kTotal Patrons"); - expect(groups.at(0).text()).to.contain("55Patrons With Active Loans"); + 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); + expectStats( + statItems.at(2).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( - "1.2kPatrons With Active Loans or Holds" + "23Patrons With Active Loans or Holds" ); + /* Circulation */ expect(groups.at(1).text()).to.contain("Circulation"); - expect(groups.at(1).text()).to.contain("100Active Loans"); - expect(groups.at(1).text()).to.contain("2kActive Holds"); - + statItems = groups.at(1).find("SingleStatListItem"); + expect(statItems.length).to.equal(2); + expectStats(statItems.at(0).props(), "Active Loans", 87); + expectStats(statItems.at(1).props(), "Active Holds", 5); + expect(groups.at(1).text()).to.contain("87Active Loans"); + expect(groups.at(1).text()).to.contain("5Active Holds"); + + /* Inventory */ expect(groups.at(2).text()).to.contain("Inventory"); - expect(groups.at(2).text()).to.contain("54.3kTitles"); - expect(groups.at(2).text()).to.contain("123.5kTotal Licenses"); - expect(groups.at(2).text()).to.contain("100kAvailable Licenses"); - + statItems = groups.at(2).find("SingleStatListItem"); + expect(statItems.length).to.equal(6); + 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"); const chart = groups.at(3).find(BarChart); expect(chart.length).to.equal(1); + const chartData = chart.props().data; + expect(chartData.length).to.equal( + defaultLibraryStatsTestData.collections.length + ); + expect(chartData[0]).to.deep.equal({ + name: "New BiblioBoard Test", + titles: 13306, + availableTitles: 13306, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 13306, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 13306, + meteredLicensesOwned: 13306, + meteredLicensesAvailable: 13306, + }); expect(chart.props().data).to.deep.equal([ { - label: "Overdrive", - "Licensed Titles": 490, - "Open Access Titles": 10, + name: "New BiblioBoard Test", + titles: 13306, + availableTitles: 13306, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 13306, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 13306, + meteredLicensesOwned: 13306, + meteredLicensesAvailable: 13306, }, { - label: "Bibliotheca", - "Licensed Titles": 400, - "Open Access Titles": 0, + name: "New Bibliotheca Test Collection", + titles: 76, + availableTitles: 64, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 76, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 76, + meteredLicensesOwned: 85, + meteredLicensesAvailable: 72, }, - { label: "Axis 360", "Licensed Titles": 300, "Open Access Titles": 0 }, { - label: "Open Bookshelf", - "Licensed Titles": 0, - "Open Access Titles": 1200, + name: "Palace Bookshelf", + titles: 7838, + availableTitles: 7838, + selfHostedTitles: 0, + openAccessTitles: 7838, + licensedTitles: 0, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 0, + meteredLicensesOwned: 0, + meteredLicensesAvailable: 0, }, - ]); - }); - - it("hides patrons and circulation groups when there are no patrons", () => { - const noPatrons = Object.assign({}, statsData, { - patrons: { - total: 0, - with_active_loans: 0, - with_active_loans_or_holds: 0, - loans: 0, - holds: 0, + { + name: "TEST Baker & Taylor", + titles: 146, + availableTitles: 134, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 146, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 146, + meteredLicensesOwned: 147, + meteredLicensesAvailable: 135, }, - }); - wrapper.setProps({ stats: noPatrons }); - const groups = wrapper.find(".stat-group"); - expect(groups.length).to.equal(2); - - expect(groups.at(0).text()).to.contain("Inventory"); - expect(groups.at(1).text()).to.contain("Collections"); - }); - - it("hides licenses in inventory when there are no licenses", () => { - const noLicenses = Object.assign({}, statsData, { - inventory: { - titles: 54321, - licenses: 0, - available_licenses: 0, + { + name: "TEST Palace Marketplace", + titles: 7753, + availableTitles: 7750, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 7753, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 7753, + meteredLicensesOwned: 305725, + meteredLicensesAvailable: 75337, }, - }); - wrapper.setProps({ stats: noLicenses }); - const groups = wrapper.find(".stat-group"); - expect(groups.length).to.equal(4); - - expect(groups.at(2).text()).to.contain("Inventory"); - expect(groups.at(2).text()).to.contain("54.3kTitles"); - expect(groups.at(2).text()).not.to.contain("Licenses"); - }); - - it("hides collections section when there are no collections", () => { - const noCollections = Object.assign({}, statsData, { collections: {} }); - wrapper.setProps({ stats: noCollections }); - const groups = wrapper.find(".stat-group"); - expect(groups.length).to.equal(3); - - expect(groups.at(0).text()).to.contain("Patrons"); - expect(groups.at(1).text()).to.contain("Circulation"); - expect(groups.at(2).text()).to.contain("Inventory"); + ]); }); }); }); diff --git a/src/components/__tests__/SingleStatListItem-test.tsx b/src/components/__tests__/SingleStatListItem-test.tsx new file mode 100644 index 000000000..791d71b68 --- /dev/null +++ b/src/components/__tests__/SingleStatListItem-test.tsx @@ -0,0 +1,57 @@ +import { expect } from "chai"; +import { shallow, ShallowWrapper } from "enzyme"; +import { beforeEach } from "mocha"; +import * as React from "react"; +import SingleStatListItem, { + SingleStatListItemProps, +} from "../SingleStatListItem"; + +describe("SingleStatListItem", () => { + const statWithTooltip: SingleStatListItemProps = { + label: "Number of Widgets", + value: 357892, + tooltip: "Total Widget Count", + }; + const humanReadableValue = "357.9k"; + const formattedValue = "357,892"; + + // Verifies the structure and content. + const expectStatContent = (item: ShallowWrapper) => { + expect(item.length).to.equal(1); + expect(item.childAt(0).text()).to.equal(humanReadableValue); + expect(item.childAt(1).text()).to.equal(statWithTooltip.label); + }; + + describe("rendering the list item", () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it("has a top-level list element, with or without a tooltip", () => { + expect(wrapper.name()).to.equal("li"); + wrapper.setProps({ tooltip: null }); + expect(wrapper.name()).to.equal("li"); + wrapper.setProps({ tooltip: undefined }); + expect(wrapper.name()).to.equal("li"); + }); + + it("shows tooltip, when available", () => { + const tooltip = wrapper.find({ "data-toggle": "tooltip" }); + expect(tooltip.length).to.equal(1); + expect(tooltip.props().title).to.contain(statWithTooltip.tooltip); + expect(tooltip.props().title).to.contain(formattedValue); + // When there's a tooltip, it wraps the content. + expectStatContent(tooltip); // When there's a tooltip, it wraps the content + }); + + it("omits tooltip, if not specified", () => { + wrapper.setProps({ tooltip: null }); + const tooltip = wrapper.find({ "data-toggle": "tooltip" }); + expect(tooltip.length).to.equal(0); + // When there's no tooltip, the list element wraps the content. + expectStatContent(wrapper); + }); + }); +}); diff --git a/src/components/__tests__/Stats-test.tsx b/src/components/__tests__/Stats-test.tsx index c8043a077..e8ceb7dba 100644 --- a/src/components/__tests__/Stats-test.tsx +++ b/src/components/__tests__/Stats-test.tsx @@ -1,162 +1,106 @@ import { expect } from "chai"; -import { stub } from "sinon"; import * as React from "react"; -import { shallow } from "enzyme"; - -import { Stats } from "../Stats"; +import { mount } from "enzyme"; +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import { Provider } from "react-redux"; +import { normalizeStatistics, Stats, StatsProps } from "../Stats"; +import LibraryStats from "../LibraryStats"; import ErrorMessage from "../ErrorMessage"; import LoadingIndicator from "@thepalaceproject/web-opds-client/lib/components/LoadingIndicator"; -import LibraryStats from "../LibraryStats"; -import { StatsData, LibraryStatsData, LibraryData } from "../../interfaces"; -describe("Stats", () => { - const libraryStatsData: LibraryStatsData = { - patrons: { - total: 3456, - with_active_loans: 55, - with_active_loans_or_holds: 1234, - loans: 100, - holds: 2000, - }, - inventory: { - titles: 54321, - licenses: 123456, - available_licenses: 100000, - }, - collections: { - Overdrive: { - licensed_titles: 500, - open_access_titles: 10, - licenses: 350, - available_licenses: 100, - }, - Bibliotheca: { - licensed_titles: 400, - open_access_titles: 0, - licenses: 300, - available_licenses: 170, - }, - "Axis 360": { - licensed_titles: 300, - open_access_titles: 0, - licenses: 280, - available_licenses: 260, - }, - "Open Bookshelf": { - licensed_titles: 0, - open_access_titles: 1200, - licenses: 0, - available_licenses: 0, - }, - }, - }; - - const totalStatsData = Object.assign({}, libraryStatsData, { - inventory: { - titles: 100000, - licenses: 234567, - available_licenses: 200000, - }, - }); +import { statisticsApiResponseData } from "../../../tests/__data__/statisticsApiResponseData"; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); - const statsData: StatsData = { - NYPL: libraryStatsData, - BPL: libraryStatsData, - total: totalStatsData, - }; +const statisticsData = normalizeStatistics(statisticsApiResponseData); +const { summaryStatistics } = statisticsData; +const libraries = statisticsData.libraries; +const librariesDataByKey = Object.assign( + {}, + ...libraries.map((l) => ({ [l.key]: l })) +); - const librariesData: LibraryData[] = [ - { short_name: "NYPL" }, - { short_name: "BPL" }, - ]; +describe("Stats", () => { + const testLibraryKey = "lyrasis-reads"; + const testLibraryApiData = librariesDataByKey[testLibraryKey]; + const testLibraryStatsData = librariesDataByKey[testLibraryKey]; describe("rendering", () => { - let wrapper; const fetchError = { status: 401, response: "test", url: "test url" }; - let fetchStats; - let fetchLibraries; - - beforeEach(() => { - fetchStats = stub().returns( - new Promise((resolve, reject) => resolve()) - ); - fetchLibraries = stub().returns( - new Promise((resolve, reject) => resolve()) - ); + const statsState = (stats: object) => ({ editor: { stats } }); + const initialState = statsState({ isLoaded: false }); + const loadedState = statsState({ + data: statisticsApiResponseData, + isLoaded: true, + }); + const errorState = statsState({ fetchError }); - wrapper = shallow( - + const ReduxProvider = ({ children, store }) => { + return ( + + {children} + ); - }); + }; + + /** Helper function to wrap the component under test in a Redux + * component. + * @param state - passed to the component in a mock store. + * @param {StatsProps} props - passed to the component as props. + */ + const mountStatsInProvider = (state?, props?) => { + const wrapper = mount(, { + wrappingComponent: ReduxProvider, + }); + const provider = wrapper.getWrappingComponent(); + !!state && provider.setProps({ store: mockStore(state) }); + return { wrapper, provider }; + }; it("shows error message", () => { + const { wrapper, provider } = mountStatsInProvider(initialState); + let error = wrapper.find(ErrorMessage); expect(error.length).to.equal(0); - wrapper.setProps({ fetchError }); + + provider.setProps({ store: mockStore(errorState) }); error = wrapper.find(ErrorMessage); expect(error.length).to.equal(1); }); it("shows/hides loading indicator", () => { + const { wrapper, provider } = mountStatsInProvider(initialState); + let loading = wrapper.find(LoadingIndicator); expect(loading.length).to.equal(1); - wrapper.setProps({ isLoaded: true }); + + provider.setProps({ store: mockStore(loadedState) }); loading = wrapper.find(LoadingIndicator); expect(loading.length).to.equal(0); }); - it("shows LibraryStats", () => { - wrapper.setProps({ isLoaded: true, library: "NYPL" }); - let libraryStats = wrapper.find(LibraryStats); - expect(libraryStats.length).to.equal(2); - - expect(libraryStats.at(0).props().stats).to.deep.equal(libraryStatsData); - expect(libraryStats.at(0).props().library).to.deep.equal( - librariesData[0] - ); - expect(libraryStats.at(1).props().stats).to.deep.equal(totalStatsData); - expect(libraryStats.at(1).props().library).to.be.undefined; + it("shows stats for only specified library, if library is specified", () => { + const { wrapper } = mountStatsInProvider(loadedState, { + library: testLibraryApiData.key, + }); - // No total stats. - wrapper.setProps({ stats: { NYPL: libraryStatsData } }); - libraryStats = wrapper.find(LibraryStats); + const libraryStats = wrapper.find(LibraryStats); expect(libraryStats.length).to.equal(1); - - expect(libraryStats.at(0).props().stats).to.deep.equal(libraryStatsData); - expect(libraryStats.at(0).props().library).to.deep.equal( - librariesData[0] + expect(libraryStats.at(0).props().stats).to.deep.equal( + testLibraryStatsData ); + expect(libraryStats.at(0).props().library).to.equal(testLibraryKey); + }); - // Still no total stats, since there's only one library. - wrapper.setProps({ - stats: { NYPL: libraryStatsData, total: totalStatsData }, - libraries: [librariesData[0]], - }); - libraryStats = wrapper.find(LibraryStats); - expect(libraryStats.length).to.equal(1); + it("shows site-wide stats when no library specified", () => { + const { wrapper } = mountStatsInProvider(loadedState); - expect(libraryStats.at(0).props().stats).to.deep.equal(libraryStatsData); - expect(libraryStats.at(0).props().library).to.deep.equal( - librariesData[0] - ); - - // No library stats. - wrapper.setProps({ - stats: { total: totalStatsData }, - libraries: librariesData, - library: null, - }); - libraryStats = wrapper.find(LibraryStats); + const libraryStats = wrapper.find(LibraryStats); expect(libraryStats.length).to.equal(1); - - expect(libraryStats.at(0).props().stats).to.deep.equal(totalStatsData); + expect(libraryStats.at(0).props().stats).to.deep.equal(summaryStatistics); expect(libraryStats.at(0).props().library).to.be.undefined; }); }); diff --git a/src/interfaces.ts b/src/interfaces.ts index 6532739ae..6891192e6 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -124,31 +124,54 @@ export interface CirculationEventData { }; } -export interface LibraryStatsData { - patrons: { - total: number; - with_active_loans: number; - with_active_loans_or_holds: number; - loans: number; - holds: number; - }; - inventory: { - titles: number; - licenses: number; - available_licenses: number; - }; - collections: { - [key: string]: { - licensed_titles: number; - open_access_titles: number; - licenses: number; - available_licenses: number; - }; - }; +export interface InventoryStatistics { + titles: number; + availableTitles: number; + selfHostedTitles: number; + openAccessTitles: number; + licensedTitles: number; + unlimitedLicenseTitles: number; + meteredLicenseTitles: number; + meteredLicensesOwned: number; + meteredLicensesAvailable: number; +} + +export interface PatronStatistics { + total: number; + withActiveLoan: number; + withActiveLoanOrHold: number; + loans: number; + holds: number; +} + +export interface LibraryStatistics { + key: string; + name: string; + patronStatistics: PatronStatistics; + inventorySummary: InventoryStatistics; + collectionIds: number[]; + collections?: CollectionInventory[]; } -export interface StatsData { - [key: string]: LibraryStatsData; +export interface CollectionInventory { + id: number; + name: string; + inventory: InventoryStatistics; +} + +export interface StatisticsData { + collections: CollectionInventory[]; + collectionIds?: number[]; + collectionIdMap?: { + [id: number]: CollectionInventory; + }; + libraries: LibraryStatistics[]; + libraryKeyMap?: { + [key: string]: LibraryStatistics; + }; + inventorySummary: InventoryStatistics; + patronSummary: PatronStatistics; + summaryStatistics?: LibraryStatistics; } export interface DiagnosticsData { diff --git a/src/reducers/__tests__/stats-test.ts b/src/reducers/__tests__/stats-test.ts index 7998cfff3..4781c96df 100644 --- a/src/reducers/__tests__/stats-test.ts +++ b/src/reducers/__tests__/stats-test.ts @@ -1,53 +1,10 @@ import { expect } from "chai"; import reducer, { StatsState } from "../stats"; -import { StatsData } from "../../interfaces"; +import { statisticsApiResponseData } from "../../../tests/__data__/statisticsApiResponseData"; import ActionCreator from "../../actions"; describe("stats reducer", () => { - const statsData: StatsData = { - NYPL: { - patrons: { - total: 3456, - with_active_loans: 55, - with_active_loans_or_holds: 1234, - loans: 100, - holds: 2000, - }, - inventory: { - titles: 54321, - licenses: 123456, - available_licenses: 100000, - }, - collections: { - Overdrive: { - licensed_titles: 500, - open_access_titles: 10, - licenses: 350, - available_licenses: 100, - }, - Bibliotheca: { - licensed_titles: 400, - open_access_titles: 0, - licenses: 300, - available_licenses: 170, - }, - "Axis 360": { - licensed_titles: 300, - open_access_titles: 0, - licenses: 280, - available_licenses: 260, - }, - "Open Bookshelf": { - licensed_titles: 0, - open_access_titles: 1200, - licenses: 0, - available_licenses: 0, - }, - }, - }, - }; - const initState: StatsState = { data: null, isFetching: false, @@ -95,9 +52,12 @@ describe("stats reducer", () => { }); it("handles STATS_LOAD", () => { - const action = { type: ActionCreator.STATS_LOAD, data: statsData }; + const action = { + type: ActionCreator.STATS_LOAD, + data: statisticsApiResponseData, + }; const newState = Object.assign({}, initState, { - data: statsData, + data: statisticsApiResponseData, isFetching: false, isLoaded: true, }); diff --git a/src/reducers/stats.ts b/src/reducers/stats.ts index 2ed069482..6bb4816c0 100644 --- a/src/reducers/stats.ts +++ b/src/reducers/stats.ts @@ -1,9 +1,10 @@ import { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; -import { StatsData } from "../interfaces"; +import { StatisticsData } from "../interfaces"; import ActionCreator from "../actions"; +import { RootState } from "../store"; export interface StatsState { - data: StatsData; + data: StatisticsData; isFetching: boolean; fetchError: RequestError; isLoaded: boolean; @@ -16,6 +17,14 @@ const initialState: StatsState = { isLoaded: false, }; +/** Map application state to StatsState properties. */ +export const stateSelector = { + data: (state: RootState) => state.editor.stats.data, + isLoaded: (state: RootState) => state.editor.stats.isLoaded, + isFetching: (state: RootState) => state.editor.stats.isFetching, + fetchError: (state: RootState) => state.editor.stats.fetchError, +}; + export default (state: StatsState = initialState, action) => { switch (action.type) { case ActionCreator.STATS_REQUEST: diff --git a/src/store.ts b/src/store.ts index 0312bbb13..3afff2438 100644 --- a/src/store.ts +++ b/src/store.ts @@ -6,11 +6,11 @@ import { Store, Reducer, } from "redux"; -import catalogReducers from "@thepalaceproject/web-opds-client/lib/reducers"; -import { State as CatalogState } from "@thepalaceproject/web-opds-client/lib/state"; -import editorReducers, { State as EditorState } from "./reducers/index"; import thunk from "redux-thunk"; +import catalogReducers from "@thepalaceproject/web-opds-client/lib/reducers/index"; +import { State as CatalogState } from "@thepalaceproject/web-opds-client/lib/state"; +import editorReducers, { State as EditorState } from "./reducers/index"; export interface RootState { editor: EditorState; diff --git a/src/stylesheets/stats.scss b/src/stylesheets/stats.scss index 41a0a2fad..7c6e9f830 100644 --- a/src/stylesheets/stats.scss +++ b/src/stylesheets/stats.scss @@ -14,6 +14,11 @@ &.stat-group-wide { grid-column-start: 1; grid-column-end: -1; + + .recharts-wrapper { + // needed for Rechart ResponsiveContainer to shrink performantly with parent + position: absolute; + } } ul { @@ -45,6 +50,12 @@ margin-right: .5rem; } + .stat-tooltip { + display: inherit; + align-items: inherit; + text-align: inherit; + } + .single-stat { display: flex; align-items: center; diff --git a/tests/__data__/statisticsApiResponseData.ts b/tests/__data__/statisticsApiResponseData.ts new file mode 100644 index 000000000..6704d5d89 --- /dev/null +++ b/tests/__data__/statisticsApiResponseData.ts @@ -0,0 +1,326 @@ +import { StatisticsData } from "../../src/interfaces"; + +export const testLibraryKey = "lyrasis-reads"; +export const noCollectionsLibraryKey = "unfunded-library"; +export const noInventoryLibraryKey = "unfunded-library"; +export const noPatronsLibraryKey = "unused-library"; +export const statisticsApiResponseData: StatisticsData = { + collections: [ + { + id: 7, + name: "TEST Palace Marketplace", + inventory: { + titles: 7753, + availableTitles: 7750, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 7753, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 7753, + meteredLicensesOwned: 305725, + meteredLicensesAvailable: 75337, + }, + }, + { + id: 8, + name: "TEST OverDrive", + inventory: { + titles: 4441, + availableTitles: 4436, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 4441, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 4441, + meteredLicensesOwned: 4072997338, + meteredLicensesAvailable: 4072997325, + }, + }, + { + id: 9, + name: "New Bibliotheca Test Collection", + inventory: { + titles: 76, + availableTitles: 64, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 76, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 76, + meteredLicensesOwned: 85, + meteredLicensesAvailable: 72, + }, + }, + { + id: 14, + name: "New BiblioBoard Test", + inventory: { + titles: 13306, + availableTitles: 13306, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 13306, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 13306, + meteredLicensesOwned: 13306, + meteredLicensesAvailable: 13306, + }, + }, + { + id: 16, + name: "TEST Baker & Taylor", + inventory: { + titles: 146, + availableTitles: 134, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 146, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 146, + meteredLicensesOwned: 147, + meteredLicensesAvailable: 135, + }, + }, + { + id: 17, + name: "A1QA Palace Marketplace", + inventory: { + titles: 7753, + availableTitles: 7750, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 7753, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 7753, + meteredLicensesOwned: 305730, + meteredLicensesAvailable: 75341, + }, + }, + { + id: 18, + name: "CALIFA Enki Test", + inventory: { + titles: 93239, + availableTitles: 91565, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 93239, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 93239, + meteredLicensesOwned: 7957965, + meteredLicensesAvailable: 7955952, + }, + }, + { + id: 19, + name: "Palace Bookshelf", + inventory: { + titles: 7838, + availableTitles: 7838, + selfHostedTitles: 0, + openAccessTitles: 7838, + licensedTitles: 0, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 0, + meteredLicensesOwned: 0, + meteredLicensesAvailable: 0, + }, + }, + { + id: 20, + name: "Test ProQuest OPDS 2", + inventory: { + titles: 1575, + availableTitles: 1575, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 1575, + unlimitedLicenseTitles: 1575, + meteredLicenseTitles: 0, + meteredLicensesOwned: 0, + meteredLicensesAvailable: 0, + }, + }, + ], + libraries: [ + { + key: "lyrasis-reads", + name: "LYRASIS Reads", + patronStatistics: { + total: 132, + withActiveLoan: 21, + withActiveLoanOrHold: 23, + loans: 87, + holds: 5, + }, + inventorySummary: { + titles: 29119, + availableTitles: 29092, + selfHostedTitles: 145, + openAccessTitles: 7838, + licensedTitles: 21281, + unlimitedLicenseTitles: 623, + meteredLicenseTitles: 20658, + meteredLicensesOwned: 319263, + meteredLicensesAvailable: 88850, + }, + collectionIds: [7, 9, 14, 16, 19], + }, + { + key: "announcements-test", + name: "Announcements Testing", + patronStatistics: { + total: 2, + withActiveLoan: 0, + withActiveLoanOrHold: 0, + loans: 0, + holds: 0, + }, + inventorySummary: { + titles: 0, + availableTitles: 0, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 0, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 0, + meteredLicensesOwned: 0, + meteredLicensesAvailable: 0, + }, + collectionIds: [], + }, + { + key: "qa1223", + name: "Test Library for QA", + patronStatistics: { + total: 1, + withActiveLoan: 0, + withActiveLoanOrHold: 0, + loans: 0, + holds: 0, + }, + inventorySummary: { + titles: 7753, + availableTitles: 7750, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 7753, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 7753, + meteredLicensesOwned: 305725, + meteredLicensesAvailable: 75337, + }, + collectionIds: [7], + }, + { + key: "od-test", + name: "Overdrive Integration Test", + patronStatistics: { + total: 3, + withActiveLoan: 0, + withActiveLoanOrHold: 0, + loans: 0, + holds: 0, + }, + inventorySummary: { + titles: 4441, + availableTitles: 4436, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 4441, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 4441, + meteredLicensesOwned: 4072997338, + meteredLicensesAvailable: 4072997325, + }, + collectionIds: [8], + }, + { + key: "unused-library", + name: "The Unused Library", + patronStatistics: { + total: 0, + withActiveLoan: 0, + withActiveLoanOrHold: 0, + loans: 0, + holds: 0, + }, + inventorySummary: { + titles: 33560, + availableTitles: 33528, + selfHostedTitles: 0, + openAccessTitles: 7838, + licensedTitles: 25722, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 25722, + meteredLicensesOwned: 4073316601, + meteredLicensesAvailable: 4073086175, + }, + collectionIds: [7, 8, 9, 14, 16, 19], + }, + { + key: "a1qa-test", + name: "A1QA Test Library", + patronStatistics: { + total: 7, + withActiveLoan: 0, + withActiveLoanOrHold: 0, + loans: 0, + holds: 0, + }, + inventorySummary: { + titles: 107230, + availableTitles: 105524, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 107230, + unlimitedLicenseTitles: 1575, + meteredLicenseTitles: 105655, + meteredLicensesOwned: 4081261265, + meteredLicensesAvailable: 4081028825, + }, + collectionIds: [8, 9, 16, 17, 18, 20], + }, + { + key: "unfunded-library", + name: "The Unfunded Public Library", + patronStatistics: { + total: 0, + withActiveLoan: 0, + withActiveLoanOrHold: 0, + loans: 0, + holds: 0, + }, + inventorySummary: { + titles: 0, + availableTitles: 0, + selfHostedTitles: 0, + openAccessTitles: 0, + licensedTitles: 0, + unlimitedLicenseTitles: 0, + meteredLicenseTitles: 0, + meteredLicensesOwned: 0, + meteredLicensesAvailable: 0, + }, + collectionIds: [], + }, + ], + inventorySummary: { + titles: 136127, + availableTitles: 134418, + selfHostedTitles: 0, + openAccessTitles: 7838, + licensedTitles: 128289, + unlimitedLicenseTitles: 1575, + meteredLicenseTitles: 126714, + meteredLicensesOwned: 4081580296, + meteredLicensesAvailable: 4081117468, + }, + patronSummary: { + total: 145, + withActiveLoan: 0, + withActiveLoanOrHold: 4, + loans: 0, + holds: 5, + }, +};