diff --git a/CLAUDE.md b/CLAUDE.md index d80dc7111..d3424b31e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,7 @@ Helpers in `tests/jest/testUtils/`: ## Code Style -- Prettier: double quotes, semicolons, trailing commas (es5), 80 char width +- Prettier (double quotes, semicolons, trailing commas (es5), printWidth 80) and ESLint are authoritative for formatting and style. The husky pre-commit hook auto-formats staged files with Prettier, and CI runs ESLint (`npm run lint:js`), so do not flag or hand-verify formatting. printWidth is a soft target, not a hard limit; lines Prettier leaves longer than 80 (e.g., interface `extends` clauses) are fine, and there is no ESLint max-len rule. - ESLint (flat config, `eslint.config.mjs`) with `jsx-a11y/strict` — runs on the whole tree in CI via `npm run lint:js` (`eslint . --max-warnings 0`, so warnings fail the build) and on staged files via the husky pre-commit hook. `npm run lint` runs ESLint (`lint:js`) followed by sass-lint. - `@typescript-eslint/no-explicit-any` is disabled (any is allowed) - Prefer template literals over string concatenation for building strings with variables diff --git a/src/components/CatalogServices.tsx b/src/components/CatalogServices.tsx index ff039670d..1675df21e 100644 --- a/src/components/CatalogServices.tsx +++ b/src/components/CatalogServices.tsx @@ -5,6 +5,10 @@ import EditableConfigList, { } from "./EditableConfigList"; import { connect } from "react-redux"; import ActionCreator from "../actions"; +import { + fetchLibrariesIfNeeded, + settledAllLibraries, +} from "../utils/allLibraries"; import { CatalogServicesData, CatalogServiceData } from "../interfaces"; import ServiceEditForm from "./ServiceEditForm"; @@ -17,6 +21,7 @@ export class CatalogServices extends EditableConfigList< > { EditForm = ServiceEditForm; listDataKey = "catalog_services"; + usesLibraryList = true; itemTypeName = "catalog service"; urlBase = "/admin/web/config/catalogServices/"; identifierKey = "id"; @@ -37,9 +42,7 @@ function mapStateToProps(state) { {}, (state.editor.catalogServices && state.editor.catalogServices.data) || {} ); - if (state.editor.libraries && state.editor.libraries.data) { - data.allLibraries = state.editor.libraries.data.libraries; - } + Object.assign(data, settledAllLibraries(state)); // fetchError = an error involving loading the list of catalog services; formError = an error upon submission // of the create/edit form. return { @@ -58,7 +61,10 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch, ownProps) { const actions = new ActionCreator(null, ownProps.csrfToken); return { - fetchData: () => dispatch(actions.fetchCatalogServices()), + fetchData: () => { + fetchLibrariesIfNeeded(dispatch, actions); + return dispatch(actions.fetchCatalogServices()); + }, editItem: (data: FormData) => dispatch(actions.editCatalogService(data)), deleteItem: (identifier: string | number) => dispatch(actions.deleteCatalogService(identifier)), diff --git a/src/components/Collections.tsx b/src/components/Collections.tsx index d80531bd3..70d009ca9 100644 --- a/src/components/Collections.tsx +++ b/src/components/Collections.tsx @@ -9,6 +9,10 @@ import { import { connect } from "react-redux"; import * as PropTypes from "prop-types"; import ActionCreator from "../actions"; +import { + fetchLibrariesIfNeeded, + settledAllLibraries, +} from "../utils/allLibraries"; import { CollectionsData, CollectionData, @@ -21,13 +25,11 @@ import CollectionReapButton from "./CollectionReapButton"; import ServiceWithRegistrationsEditForm from "./ServiceWithRegistrationsEditForm"; import TrashIcon from "./icons/TrashIcon"; -export interface CollectionsStateProps - extends EditableConfigListStateProps { +export interface CollectionsStateProps extends EditableConfigListStateProps { isFetchingLibraryRegistrations?: boolean; } -export interface CollectionsDispatchProps - extends EditableConfigListDispatchProps { +export interface CollectionsDispatchProps extends EditableConfigListDispatchProps { registerLibrary: (data: FormData) => Promise; fetchLibraryRegistrations?: () => Promise; importCollection: ( @@ -38,13 +40,12 @@ export interface CollectionsDispatchProps } export interface CollectionsProps - extends CollectionsStateProps, + extends + CollectionsStateProps, CollectionsDispatchProps, EditableConfigListOwnProps {} -export class CollectionEditForm extends ServiceWithRegistrationsEditForm< - CollectionsData -> { +export class CollectionEditForm extends ServiceWithRegistrationsEditForm { context: ServiceWithRegistrationsEditForm["context"] & { importCollection: ( collectionId: string | number, @@ -114,6 +115,7 @@ export class Collections extends GenericEditableConfigList< > { EditForm = CollectionEditForm; listDataKey = "collections"; + usesLibraryList = true; itemTypeName = "collection"; urlBase = "/admin/web/config/collections/"; identifierKey = "id"; @@ -146,7 +148,7 @@ export class Collections extends GenericEditableConfigList< } protected getAllLibraries() { - return this.props.data?.allLibraries ?? []; + return this.props.data.allLibraries; } componentDidMount() { @@ -209,9 +211,7 @@ function mapStateToProps(state) { {}, (state.editor.collections && state.editor.collections.data) || {} ); - if (state.editor.libraries && state.editor.libraries.data) { - data.allLibraries = state.editor.libraries.data.libraries; - } + Object.assign(data, settledAllLibraries(state)); // fetchError = an error involving loading the list of collections; formError = an error upon // submission of the create/edit form. return { @@ -228,7 +228,10 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch, ownProps) { const actions = new ActionCreator(null, ownProps.csrfToken); return { - fetchData: () => dispatch(actions.fetchCollections()), + fetchData: () => { + fetchLibrariesIfNeeded(dispatch, actions); + return dispatch(actions.fetchCollections()); + }, editItem: (data: FormData) => dispatch(actions.editCollection(data)), deleteItem: (identifier: string | number) => dispatch(actions.deleteCollection(identifier)), diff --git a/src/components/DiscoveryServices.tsx b/src/components/DiscoveryServices.tsx index 87ace3707..2d9659f52 100644 --- a/src/components/DiscoveryServices.tsx +++ b/src/components/DiscoveryServices.tsx @@ -8,6 +8,10 @@ import { import { connect } from "react-redux"; import * as PropTypes from "prop-types"; import ActionCreator from "../actions"; +import { + fetchLibrariesIfNeeded, + settledAllLibraries, +} from "../utils/allLibraries"; import { DiscoveryServicesData, DiscoveryServiceData, @@ -45,6 +49,7 @@ export class DiscoveryServices extends GenericEditableConfigList< > { EditForm = DiscoveryServiceEditForm; listDataKey = "discovery_services"; + usesLibraryList = true; itemTypeName = "discovery service"; urlBase = "/admin/web/config/discovery/"; identifierKey = "id"; @@ -76,12 +81,18 @@ export class DiscoveryServices extends GenericEditableConfigList< private registeredLibraries( item: DiscoveryServiceData ): LibraryDataWithStatus[] | undefined { - const registrations = this.props.data?.libraryRegistrations; + const registrations = this.props.data.libraryRegistrations; if (!registrations) return undefined; const serviceReg = registrations.find((r) => r.id === item.id); return (serviceReg?.libraries ?? []).filter((l) => l.status === "success"); } + // This tab's disclosure lists registered libraries, whose names and links + // fall back to the registration records themselves. + protected librariesUnavailableMessage(): string { + return "The library list failed to load. Registered libraries are shown using registration data, which may be out of date."; + } + protected formatAssociatedCount(count: number): string { return count === 0 ? "no registered libraries" @@ -95,7 +106,10 @@ export class DiscoveryServices extends GenericEditableConfigList< ): Array<{ label: string; suffix?: string; href?: string }> | undefined { const registered = this.registeredLibraries(item); if (registered === undefined) return undefined; - const allLibraries = this.props.data?.allLibraries ?? []; + const allLibraries = this.getAllLibraries(); + // Hold the panel until the sitewide list settles, so labels render + // once, in their final linked form. + if (!allLibraries) return undefined; return registered.map((lib) => { const meta = allLibraries.find((l) => l.short_name === lib.short_name); return { @@ -120,9 +134,7 @@ function mapStateToProps(state) { (state.editor.discoveryServices && state.editor.discoveryServices.data) || {} ); - if (state.editor.libraries && state.editor.libraries.data) { - data.allLibraries = state.editor.libraries.data.libraries; - } + Object.assign(data, settledAllLibraries(state)); if ( state.editor.discoveryServiceLibraryRegistrations && state.editor.discoveryServiceLibraryRegistrations.data @@ -156,7 +168,10 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch, ownProps) { const actions = new ActionCreator(null, ownProps.csrfToken); return { - fetchData: () => dispatch(actions.fetchDiscoveryServices()), + fetchData: () => { + fetchLibrariesIfNeeded(dispatch, actions); + return dispatch(actions.fetchDiscoveryServices()); + }, editItem: (data: FormData) => dispatch(actions.editDiscoveryService(data)), deleteItem: (identifier: string | number) => dispatch(actions.deleteDiscoveryService(identifier)), diff --git a/src/components/EditableConfigList.tsx b/src/components/EditableConfigList.tsx index 7f7c3474f..bffadc37c 100644 --- a/src/components/EditableConfigList.tsx +++ b/src/components/EditableConfigList.tsx @@ -15,6 +15,8 @@ import Admin from "../models/Admin"; import * as PropTypes from "prop-types"; import { navigateTo } from "../utils/navigate"; import { libraryConfigHref, libraryLabel } from "../utils/sharedFunctions"; +import LibrariesRefreshWarning from "./LibrariesRefreshWarning"; +import LibrariesLoadStatus from "./LibrariesLoadStatus"; export interface EditableConfigListStateProps { data?: T; @@ -111,6 +113,9 @@ export abstract class GenericEditableConfigList< abstract labelKey: string; adminLevel?: number; limitOne = false; + /** True on lists whose data merges settledAllLibraries; gates the + * library-list status line in list mode. */ + usesLibraryList = false; links?: { [key: string]: JSX.Element }; AdditionalContent?: new ( props: AdditionalContentProps @@ -177,6 +182,30 @@ export abstract class GenericEditableConfigList< {this.props.fetchError && !this.props.editOrCreate && ( )} + {/* In list mode only; the edit and create forms raise their own + library-list status line and failure alerts. The status line is + additionally gated on usesLibraryList, since lists that never + merge settledAllLibraries would read as loading forever. */} + {!this.props.editOrCreate && this.usesLibraryList && ( + + )} + {(this.props.data as any)?.allLibrariesError && + !this.props.editOrCreate && ( + {this.librariesUnavailableMessage()} + )} + {!this.props.editOrCreate && ( + + )} {this.props.formError && this.props.editOrCreate && ( )} @@ -267,7 +296,8 @@ export abstract class GenericEditableConfigList< /** * Returns the full list of libraries known to the server, used to resolve - * short names to display names and UUIDs for the associated-items panel. + * short names to display names and UUIDs for the associated-items panel, + * or undefined while that list has not settled yet. * * The base implementation accesses `data.allLibraries` via an `any` cast * because the generic `T` is not constrained to include that field (e.g. @@ -275,8 +305,8 @@ export abstract class GenericEditableConfigList< * `allLibraries` (e.g. `Collections`, `IndividualAdmins`) should override * this method with a type-safe accessor to avoid the cast. */ - protected getAllLibraries(): LibraryData[] { - return (this.props.data as any)?.allLibraries ?? []; + protected getAllLibraries(): LibraryData[] | undefined { + return (this.props.data as any).allLibraries; } /** @@ -285,6 +315,15 @@ export abstract class GenericEditableConfigList< * subclasses that use different terminology (e.g. "registered libraries", * "roles"). */ + /** + * Message for the list-mode alert shown when the sitewide library list + * failed to load. Override where the disclosure panel lists something + * other than libraries (see IndividualAdmins). + */ + protected librariesUnavailableMessage(): string { + return "The library list failed to load. Associated libraries are shown by short name only."; + } + protected formatAssociatedCount(count: number): string { return count === 0 ? "no libraries" @@ -298,7 +337,9 @@ export abstract class GenericEditableConfigList< * for a given item, or `undefined` if the panel does not apply to this item. * * Return semantics (used by `renderLi` to drive toggle visibility): - * - `undefined` → the feature does not apply; no toggle is rendered. + * - `undefined` → the feature does not apply to this item, or the + * sitewide library list has not settled yet; no toggle + * or count is rendered. * - `[]` → the feature applies but there are no associations; * a disabled toggle is rendered. * - `[…entries]` → associations exist; an enabled toggle is rendered. @@ -318,6 +359,10 @@ export abstract class GenericEditableConfigList< ?.libraries; if (libraries === undefined) return undefined; const allLibraries = this.getAllLibraries(); + // Hold the panel until the sitewide list settles, so labels render + // once, in their final linked form, instead of flashing bare short + // names that get rewritten when the list arrives. + if (!allLibraries) return undefined; return libraries.map((lib) => { const libraryData = allLibraries.find( (l) => l.short_name === lib.short_name diff --git a/src/components/IndividualAdminEditForm.tsx b/src/components/IndividualAdminEditForm.tsx index 187769845..18831a8fd 100644 --- a/src/components/IndividualAdminEditForm.tsx +++ b/src/components/IndividualAdminEditForm.tsx @@ -2,9 +2,16 @@ import * as React from "react"; import * as PropTypes from "prop-types"; import EditableInput from "./EditableInput"; import { clearForm, libraryLabel } from "../utils/sharedFunctions"; -import { IndividualAdminsData, IndividualAdminData } from "../interfaces"; +import { + IndividualAdminsData, + IndividualAdminData, + LibraryData, +} from "../interfaces"; import Admin from "../models/Admin"; import { Panel, Form } from "library-simplified-reusable-components"; +import { Alert } from "react-bootstrap"; +import LibrariesLoadStatus from "./LibrariesLoadStatus"; +import LibrariesRefreshWarning from "./LibrariesRefreshWarning"; import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; @@ -58,6 +65,7 @@ export default class IndividualAdminEditForm extends React.Component< this.submit = this.submit.bind(this); this.renderForm = this.renderForm.bind(this); this.renderRoleForm = this.renderRoleForm.bind(this); + this.renderRolesTable = this.renderRolesTable.bind(this); } UNSAFE_componentWillReceiveProps(nextProps) { @@ -143,6 +151,10 @@ export default class IndividualAdminEditForm extends React.Component< } renderRoleForm() { + // Wait for the sitewide library list before showing per-library roles, + // mirroring the Libraries panel in ServiceEditForm. + const { allLibraries, allLibrariesError, allLibrariesRefreshError } = + this.props.data; return (
Roles @@ -156,86 +168,114 @@ export default class IndividualAdminEditForm extends React.Component< checked={this.isSelected("system")} onChange={() => this.handleRoleChange("system")} /> - - + + {allLibrariesError && ( + + {this.props.item + ? "The library list failed to load. This admin's library roles cannot be shown, and no roles can be changed." + : "The library list failed to load. Sitewide roles can still be assigned, but per-library roles cannot."} + + )} + + {/* For an existing admin a failed load disables all role edits, so + an empty table shell would only add noise; drop it. */} + {allLibraries && + !(this.props.item && allLibrariesError) && + this.renderRolesTable(allLibraries, allLibrariesError)} + + ); + } + + renderRolesTable( + allLibraries: LibraryData[], + allLibrariesError?: FetchErrorData + ) { + return ( +
+ + + + + + + + + {allLibraries.length === 0 && !allLibrariesError && ( - - + + )} + {allLibraries.map((library) => ( + + + - - - {this.props.data && - this.props.data.allLibraries && - this.props.data.allLibraries.map((library) => ( - - - - - - ))} - -
+ this.handleRoleChange("manager-all")} + /> + + this.handleRoleChange("librarian-all")} + /> +
+ No libraries are configured.
{libraryLabel(library.name, library.short_name)} this.handleRoleChange("manager-all")} + disabled={this.isDisabled("manager", library.short_name)} + name={`manager-${library.short_name}`} + ref={(componentInstance) => { + this.libraryManagerRefs[library.short_name] = + componentInstance; + }} + label="" + aria-label={`Administrator of ${library.short_name}`} + checked={this.isSelected("manager", library.short_name)} + onChange={() => + this.handleRoleChange("manager", library.short_name) + } /> - - + + this.handleRoleChange("librarian-all")} + disabled={this.isDisabled("librarian", library.short_name)} + name={`librarian-${library.short_name}`} + ref={(componentInstance) => { + this.librarianRefs[library.short_name] = componentInstance; + }} + label="" + aria-label={`User of ${library.short_name}`} + checked={this.isSelected("librarian", library.short_name)} + onChange={() => + this.handleRoleChange("librarian", library.short_name) + } /> - +
{libraryLabel(library.name, library.short_name)} - { - this.libraryManagerRefs[library.short_name] = - componentInstance; - }} - label="" - aria-label={`Administrator of ${library.short_name}`} - checked={this.isSelected("manager", library.short_name)} - onChange={() => - this.handleRoleChange("manager", library.short_name) - } - /> - - { - this.librarianRefs[library.short_name] = - componentInstance; - }} - label="" - aria-label={`User of ${library.short_name}`} - checked={this.isSelected("librarian", library.short_name)} - onChange={() => - this.handleRoleChange("librarian", library.short_name) - } - /> -
-
+ ))} + + ); } @@ -269,6 +309,26 @@ export default class IndividualAdminEditForm extends React.Component< if (this.props.disabled) { return true; } + // An existing admin is fully locked while the list is missing or + // failed to load: their per-library roles cannot be shown, and the + // sitewide toggles would rewrite those hidden roles wholesale. + if ( + this.props.item && + (!this.props.data.allLibraries || this.props.data.allLibrariesError) + ) { + return true; + } + // Only the per-library toggles rebuild roles from the list (their + // un-check branches expand sitewide roles using it), so a stale list + // locks just those. The sitewide toggles replace the role set without + // consulting the list and stay assignable, so the form cannot be + // reduced to submitting a roleless admin. + if ( + this.props.data.allLibrariesRefreshError && + (role === "manager" || role === "librarian") + ) { + return true; + } if (role === "system" || this.isSelected("system")) { return !this.context.admin.isSystemAdmin(); } diff --git a/src/components/IndividualAdmins.tsx b/src/components/IndividualAdmins.tsx index 2ee0f6d8f..ff25f0342 100644 --- a/src/components/IndividualAdmins.tsx +++ b/src/components/IndividualAdmins.tsx @@ -6,10 +6,15 @@ import EditableConfigList, { } from "./EditableConfigList"; import { connect } from "react-redux"; import ActionCreator from "../actions"; +import { + fetchLibrariesIfNeeded, + settledAllLibraries, +} from "../utils/allLibraries"; import { IndividualAdminsData, IndividualAdminData, AdminRoleData, + LibraryData, } from "../interfaces"; import Admin from "../models/Admin"; import { libraryConfigHref, libraryLabel } from "../utils/sharedFunctions"; @@ -24,6 +29,7 @@ export class IndividualAdmins extends EditableConfigList< > { EditForm = IndividualAdminEditForm; listDataKey = "individualAdmins"; + usesLibraryList = true; itemTypeName = "individual admin"; urlBase = "/admin/web/config/individualAdmins/"; identifierKey = "email"; @@ -34,14 +40,16 @@ export class IndividualAdmins extends EditableConfigList< admin: PropTypes.object.isRequired, }; - private getRolesSummary(item: IndividualAdminData): Array<{ + private getRolesSummary( + item: IndividualAdminData, + allLibraries: LibraryData[] + ): Array<{ label: string; suffix?: string; href?: string; pinned?: boolean; }> { const roles: AdminRoleData[] = item.roles || []; - const allLibraries = this.getAllLibraries(); const getLibraryLabel = (shortName: string) => libraryLabel( @@ -101,7 +109,12 @@ export class IndividualAdmins extends EditableConfigList< } protected getAllLibraries() { - return this.props.data?.allLibraries ?? []; + return this.props.data.allLibraries; + } + + // This tab's disclosure lists roles, not libraries. + protected librariesUnavailableMessage(): string { + return "The library list failed to load. Roles are shown by library short name only."; } protected formatAssociatedCount(count: number): string { @@ -114,12 +127,16 @@ export class IndividualAdmins extends EditableConfigList< | Array<{ label: string; suffix?: string; href?: string; pinned?: boolean }> | undefined { if (!item.roles) return undefined; - // System admins have a single implicit role that isn't library-scoped; - // show a synthetic "sysadmin" entry rather than the library-role summary. + // System admins have a single implicit role that isn't library-scoped + // and can never be rewritten by the list; render it immediately. if (item.roles.some((r) => r.role === "system")) { return [{ label: "sysadmin" }]; } - return this.getRolesSummary(item); + // Hold library-scoped rows until the sitewide list settles, so labels + // render once, in their final linked form. + const allLibraries = this.getAllLibraries(); + if (!allLibraries) return undefined; + return this.getRolesSummary(item, allLibraries); } canCreate() { @@ -176,9 +193,7 @@ function mapStateToProps(state) { {}, (state.editor.individualAdmins && state.editor.individualAdmins.data) || {} ); - if (state.editor.libraries && state.editor.libraries.data) { - data.allLibraries = state.editor.libraries.data.libraries; - } + Object.assign(data, settledAllLibraries(state)); // fetchError = an error involving loading the list of individual admins; formError = an error upon submission of the // create/edit form. return { @@ -197,7 +212,14 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch, ownProps) { const actions = new ActionCreator(null, ownProps.csrfToken); return { - fetchData: () => dispatch(actions.fetchIndividualAdmins()), + fetchData: () => { + // The pre-auth setup page has no admin yet, so a libraries request + // could only fail; skip it there. + if (!ownProps.settingUp) { + fetchLibrariesIfNeeded(dispatch, actions); + } + return dispatch(actions.fetchIndividualAdmins()); + }, editItem: (data: FormData) => dispatch(actions.editIndividualAdmin(data)), deleteItem: (identifier: string | number) => dispatch(actions.deleteIndividualAdmin(identifier)), diff --git a/src/components/LibrariesLoadStatus.tsx b/src/components/LibrariesLoadStatus.tsx new file mode 100644 index 000000000..47e8eab66 --- /dev/null +++ b/src/components/LibrariesLoadStatus.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { AllLibrariesData } from "../interfaces"; + +export type LibrariesLoadStatusProps = AllLibrariesData; + +/** + * Live status line for the sitewide library list load. It stays mounted so + * that the settled text is a content change, which screen readers announce + * (content present at mount is not announced). The settled text is + * visually hidden; the surrounding UI shows the outcome. On the failure + * paths the text empties instead: the adjacent Alert renders role="alert" + * and announces the outcome itself, so a status text there would be read + * twice. This region covers the one transition nothing else announces, + * loading to cleanly loaded. + */ +export default function LibrariesLoadStatus({ + allLibraries, + allLibrariesError, + allLibrariesRefreshError, +}: LibrariesLoadStatusProps): JSX.Element { + const loading = !allLibraries; + return ( +

+ {loading + ? "Loading libraries..." + : allLibrariesError || allLibrariesRefreshError + ? "" + : "Libraries loaded."} +

+ ); +} diff --git a/src/components/LibrariesRefreshWarning.tsx b/src/components/LibrariesRefreshWarning.tsx new file mode 100644 index 000000000..5eafb2dba --- /dev/null +++ b/src/components/LibrariesRefreshWarning.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import { Alert } from "react-bootstrap"; +import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; + +export interface LibrariesRefreshWarningProps { + allLibrariesRefreshError?: FetchErrorData; + detail?: string; +} + +/** + * Warning that the sitewide library list is being served from its last + * loaded copy because a refresh failed. Renders nothing while there is no + * refresh error. `detail` appends a panel-specific consequence to the + * shared wording. + */ +export default function LibrariesRefreshWarning({ + allLibrariesRefreshError, + detail, +}: LibrariesRefreshWarningProps): JSX.Element | null { + if (!allLibrariesRefreshError) { + return null; + } + return ( + + The library list could not be refreshed. Showing the last loaded list, + which may be out of date. + {detail ? ` ${detail}` : ""} + + ); +} diff --git a/src/components/LibraryRegistration.tsx b/src/components/LibraryRegistration.tsx index b5e9fc4f4..99df64429 100644 --- a/src/components/LibraryRegistration.tsx +++ b/src/components/LibraryRegistration.tsx @@ -9,6 +9,7 @@ import { LibraryData, } from "../interfaces"; import LibraryConfigLink from "./LibraryConfigLink"; +import { Alert } from "react-bootstrap"; export interface LibraryRegistrationState { registration_stage?: { [key: string]: string } | null; @@ -56,6 +57,19 @@ export default class LibraryRegistration extends React.Component< } render(): JSX.Element { + // The Libraries panel above this section explains a failed list load; + // say why registration is unavailable too rather than vanishing. + if ( + this.props.item && + this.protocolSupportsType("supports_registration") && + this.props.data.allLibrariesError + ) { + return ( + + Libraries cannot be registered: the library list is unavailable. + + ); + } if ( this.props.item && this.protocolSupportsType("supports_registration") && diff --git a/src/components/MetadataServices.tsx b/src/components/MetadataServices.tsx index 59af0b356..599fd0b57 100644 --- a/src/components/MetadataServices.tsx +++ b/src/components/MetadataServices.tsx @@ -6,6 +6,10 @@ import EditableConfigList, { } from "./EditableConfigList"; import { connect } from "react-redux"; import ActionCreator from "../actions"; +import { + fetchLibrariesIfNeeded, + settledAllLibraries, +} from "../utils/allLibraries"; import { MetadataServicesData, MetadataServiceData } from "../interfaces"; import ServiceEditForm from "./ServiceEditForm"; @@ -18,6 +22,7 @@ export class MetadataServices extends EditableConfigList< > { EditForm = ServiceEditForm; listDataKey = "metadata_services"; + usesLibraryList = true; itemTypeName = "metadata service"; urlBase = "/admin/web/config/metadata/"; identifierKey = "id"; @@ -49,9 +54,7 @@ function mapStateToProps(state) { {}, (state.editor.metadataServices && state.editor.metadataServices.data) || {} ); - if (state.editor.libraries && state.editor.libraries.data) { - data.allLibraries = state.editor.libraries.data.libraries; - } + Object.assign(data, settledAllLibraries(state)); // fetchError = an error involving loading the list of metadata services; formError = an error upon submission of the // create/edit form. return { @@ -70,7 +73,10 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch, ownProps) { const actions = new ActionCreator(null, ownProps.csrfToken); return { - fetchData: () => dispatch(actions.fetchMetadataServices()), + fetchData: () => { + fetchLibrariesIfNeeded(dispatch, actions); + return dispatch(actions.fetchMetadataServices()); + }, editItem: (data: FormData) => dispatch(actions.editMetadataService(data)), deleteItem: (identifier: string | number) => dispatch(actions.deleteMetadataService(identifier)), diff --git a/src/components/PatronAuthServices.tsx b/src/components/PatronAuthServices.tsx index aee6770bc..e85610b38 100644 --- a/src/components/PatronAuthServices.tsx +++ b/src/components/PatronAuthServices.tsx @@ -6,6 +6,10 @@ import EditableConfigList, { } from "./EditableConfigList"; import { connect } from "react-redux"; import ActionCreator from "../actions"; +import { + fetchLibrariesIfNeeded, + settledAllLibraries, +} from "../utils/allLibraries"; import { PatronAuthServicesData, PatronAuthServiceData } from "../interfaces"; import PatronAuthServiceEditForm from "./PatronAuthServiceEditForm"; import NeighborhoodAnalyticsForm from "./NeighborhoodAnalyticsForm"; @@ -22,6 +26,7 @@ export class PatronAuthServices extends EditableConfigList< ExtraFormSection = NeighborhoodAnalyticsForm; extraFormKey = "neighborhood_mode"; listDataKey = "patron_auth_services"; + usesLibraryList = true; itemTypeName = "patron authentication service"; urlBase = "/admin/web/config/patronAuth/"; identifierKey = "id"; @@ -63,9 +68,7 @@ function mapStateToProps(state, ownProps) { (state.editor.patronAuthServices && state.editor.patronAuthServices.data) || {} ); - if (state.editor.libraries && state.editor.libraries.data) { - data.allLibraries = state.editor.libraries.data.libraries; - } + Object.assign(data, settledAllLibraries(state)); // fetchError = an error involving loading the list of patron auth services; formError = an error upon submission // of the create/edit form. return { @@ -85,7 +88,10 @@ function mapStateToProps(state, ownProps) { function mapDispatchToProps(dispatch, ownProps) { const actions = new ActionCreator(null, ownProps.csrfToken); return { - fetchData: () => dispatch(actions.fetchPatronAuthServices()), + fetchData: () => { + fetchLibrariesIfNeeded(dispatch, actions); + return dispatch(actions.fetchPatronAuthServices()); + }, editItem: (data: FormData) => dispatch(actions.editPatronAuthService(data)), deleteItem: (identifier: string | number) => dispatch(actions.deletePatronAuthService(identifier)), diff --git a/src/components/ServiceEditForm.tsx b/src/components/ServiceEditForm.tsx index 5db07d13e..bf72af55f 100644 --- a/src/components/ServiceEditForm.tsx +++ b/src/components/ServiceEditForm.tsx @@ -13,7 +13,10 @@ import { } from "../interfaces"; import { clearForm, libraryLabel } from "../utils/sharedFunctions"; import LibraryConfigLink from "./LibraryConfigLink"; +import LibrariesLoadStatus from "./LibrariesLoadStatus"; +import LibrariesRefreshWarning from "./LibrariesRefreshWarning"; import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; +import { Alert } from "react-bootstrap"; export interface ServiceEditFormProps { data: T; @@ -372,20 +375,38 @@ export default class ServiceEditForm< } renderLibrariesForm(protocol: ProtocolData, disabled: boolean) { - return ( + // allLibraries is undefined until the library list request settles; + // wait rather than flash unlinked short names that change on arrival. + const loading = !this.props.data.allLibraries; + const librariesFieldset = !loading && (
Libraries + {this.props.data.allLibrariesError && ( + + The library list failed to load. Associated libraries are shown by + short name only, and library associations cannot be added or + removed. + + )} + + {this.props.data.allLibraries.length === 0 && + !this.props.data.allLibrariesError && ( +

No libraries are configured.

+ )}
{this.state.libraries.map((library) => (
this.removeLibrary(library)} confirmRemoval={() => this.isLibraryRemovalPermitted(library)} ref={library.short_name} > - {this.props.data && - this.props.data.protocols && + {this.props.data.protocols && this.protocolHasLibrarySettings(protocol) && ( )} {!( - this.props.data && this.props.data.protocols && this.protocolHasLibrarySettings(protocol) ) && this.renderLibraryLabel(library.short_name)} {this.isExpanded(library) && (
- {this.props.data && - this.props.data.protocols && + {this.props.data.protocols && this.protocolLibrarySettings(protocol) && this.protocolLibrarySettings(protocol).map((setting) => ( {this.state.selectedLibrary && (
- {this.props.data && - this.props.data.protocols && + {this.props.data.protocols && this.protocolLibrarySettings(protocol) && this.protocolLibrarySettings(protocol).map((setting) => ( ); + return ( + <> + + {librariesFieldset} + + ); } availableProtocols(props?): ProtocolData[] { @@ -600,7 +628,9 @@ export default class ServiceEditForm< } getLibrary(shortName: string): LibraryData { - const libraries = (this.props.data && this.props.data.allLibraries) || []; + // Only called from inside the Libraries panel, which renders after + // allLibraries has settled. + const libraries = this.props.data.allLibraries; for (const library of libraries) { if (library.short_name === shortName) { return library; @@ -623,7 +653,9 @@ export default class ServiceEditForm< } availableLibraries(): LibraryData[] { - const libraries = (this.props.data && this.props.data.allLibraries) || []; + // Only called from inside the Libraries panel, which renders after + // allLibraries has settled. + const libraries = this.props.data.allLibraries; return libraries.filter((library) => { for (const stateLibrary of this.state.libraries) { if (stateLibrary.short_name === library.short_name) { diff --git a/src/interfaces.ts b/src/interfaces.ts index babcdb8a0..23f31350b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,7 @@ /* eslint-disable */ +import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; + 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 @@ -384,9 +386,22 @@ export interface ServiceData { goal?: string; } -export interface ServicesData { - protocols: ProtocolData[]; +/** + * The sitewide library list fields produced by settledAllLibraries and + * merged into a config page's data. Extended by each data type whose page + * resolves library short names against the list. + */ +export interface AllLibrariesData { + /** The sitewide library list; undefined until its request settles. */ allLibraries?: LibraryData[]; + /** Set when the sitewide library list failed to load. */ + allLibrariesError?: FetchErrorData; + /** Set when the list is loaded but a later refresh of it failed. */ + allLibrariesRefreshError?: FetchErrorData; +} + +export interface ServicesData extends AllLibrariesData { + protocols: ProtocolData[]; } export interface ServicesWithRegistrationsData extends ServicesData { @@ -423,9 +438,8 @@ export interface IndividualAdminData { roles?: AdminRoleData[]; } -export interface IndividualAdminsData { +export interface IndividualAdminsData extends AllLibrariesData { individualAdmins?: IndividualAdminData[]; - allLibraries?: LibraryData[]; } export interface PatronAuthServiceData extends ServiceData {} diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 8f41f0aa1..08dfc72d9 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -5,7 +5,7 @@ import bookCoverPreview, { BookCoverPreviewState } from "./bookCoverPreview"; import bookCover from "./bookCover"; import customListsForBook from "./customListsForBook"; import diagnostics from "./diagnostics"; -import libraries from "./libraries"; +import libraries, { LibrariesState } from "./libraries"; import collections from "./collections"; import individualAdmins from "./individualAdmins"; import patronAuthServices from "./patronAuthServices"; @@ -38,7 +38,6 @@ import { FetchEditState } from "./createFetchEditReducer"; import { RegisterLibraryState } from "./createRegisterLibraryReducer"; import patronManager from "./managePatrons"; import { - LibrariesData, CollectionsData, IndividualAdminsData, PatronAuthServicesData, @@ -65,7 +64,7 @@ export interface State { bookCover: FetchEditState; customListsForBook: FetchEditState; diagnostics: FetchEditState; - libraries: FetchEditState; + libraries: LibrariesState; collections: FetchEditState; individualAdmins: FetchEditState; patronAuthServices: FetchEditState; @@ -74,9 +73,7 @@ export interface State { catalogServices: FetchEditState; discoveryServices: FetchEditState; registerLibraryWithDiscoveryService: RegisterLibraryState; - discoveryServiceLibraryRegistrations: FetchEditState< - LibraryRegistrationsData - >; + discoveryServiceLibraryRegistrations: FetchEditState; customLists: FetchEditState; customListDetails: FetchMoreCustomListDetails; customListEditor: CustomListEditorState; diff --git a/src/reducers/libraries.ts b/src/reducers/libraries.ts index 3f94ea55a..0e1cf5106 100644 --- a/src/reducers/libraries.ts +++ b/src/reducers/libraries.ts @@ -1,8 +1,81 @@ import { LibrariesData } from "../interfaces"; +import { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import ActionCreator from "../actions"; -import createFetchEditReducer from "./createFetchEditReducer"; +import createFetchEditReducer, { + FetchEditState, +} from "./createFetchEditReducer"; -export default createFetchEditReducer( +export interface LibrariesState extends FetchEditState { + /** + * The failure that a retry now in flight is retrying. Kept under its own + * key so that fetchError keeps meaning "the current request failed" for + * direct consumers (e.g. the Libraries config page); consumers that want + * to keep showing the old failure during the retry read this instead. + * Cleared when the retry settles (FAILURE or LOAD). + */ + lastFetchError?: RequestError | null; + /** + * The last loaded list while a refetch is in flight. Kept under its own + * key so that data keeps its normal request lifecycle for the Libraries + * config page (whose edit form must unmount during the post-save + * refetch); settledAllLibraries serves this copy so the other config + * tabs do not flip back to "loading". Kept through a FAILURE so a stale + * copy stays available; cleared when a fresh list LOADs. + */ + lastData?: LibrariesData | null; +} + +const fetchEditReducer = createFetchEditReducer( ActionCreator.LIBRARIES, ActionCreator.EDIT_LIBRARY ); + +const librariesAction = (action: string) => + `${ActionCreator.LIBRARIES}_${action}`; + +const editLibraryAction = (action: string) => + `${ActionCreator.EDIT_LIBRARY}_${action}`; + +export default (state: LibrariesState | undefined, action): LibrariesState => { + const next: LibrariesState = fetchEditReducer(state, action); + if (action.type === librariesAction(ActionCreator.REQUEST)) { + // Fall back to the already-retained copies so that a second request + // starting before the first settles (e.g. the header's fetch and the + // Libraries tab's fetch overlap) does not discard them. A failure + // recorded beside current data is dropped, not retained: consumers + // already ignore it (see settledAllLibraries), so carrying it would + // resurface it against the retained copy during the refetch. + return { + ...next, + lastData: state?.data ?? state?.lastData ?? null, + lastFetchError: state?.data + ? null + : (state?.fetchError ?? state?.lastFetchError ?? null), + }; + } + if (action.type === librariesAction(ActionCreator.FAILURE)) { + // fetchError now carries the new failure; lastData rides along in + // `next` so consumers can keep serving the stale list. + return { ...next, lastFetchError: null }; + } + if (action.type === librariesAction(ActionCreator.LOAD)) { + return { ...next, lastData: null, lastFetchError: null }; + } + if ( + action.type === editLibraryAction(ActionCreator.REQUEST) || + action.type === editLibraryAction(ActionCreator.SUCCESS) + ) { + // The base reducer clears fetchError when a library form submit starts + // or succeeds; keep a pending list-fetch failure as lastFetchError so + // consumers do not mistake the state for a cleanly loaded list. As in + // the LIBRARIES_REQUEST branch, a failure beside current data is + // dropped, since consumers ignore it. + return { + ...next, + lastFetchError: state?.data + ? null + : (state?.fetchError ?? state?.lastFetchError ?? null), + }; + } + return next; +}; diff --git a/src/utils/allLibraries.ts b/src/utils/allLibraries.ts new file mode 100644 index 000000000..6882480b9 --- /dev/null +++ b/src/utils/allLibraries.ts @@ -0,0 +1,85 @@ +// Helpers shared by the config pages whose "Libraries" sections resolve +// associated-library short names against the sitewide library list kept in +// state.editor.libraries. + +import ActionCreator from "../actions"; +import { AllLibrariesData } from "../interfaces"; +import { LibrariesState } from "../reducers/libraries"; + +/** + * Returns the sitewide library list once its request has settled, or an + * empty object while the request is still pending. A failed request settles + * to an empty list plus the error. Consumers can therefore tell "still + * loading" (allLibraries undefined) apart from "no libraries" ([]), and can + * report a failure. A retry after a failure also counts as settled (the + * reducer keeps the old failure as lastFetchError), so the previous error + * stays visible while the retry runs. + * + * A failure with no list at all is blocking (allLibrariesError). A failure + * beside the retained copy of the list means the copy may be out of date + * (allLibrariesRefreshError). A failure beside a current list is reported + * as neither: the list on screen is up to date, and concurrent requests + * with mixed outcomes must not degrade a working page. + * + * Merge the result into the `data` prop built by a config page's + * mapStateToProps. + */ +export function settledAllLibraries(state: { + editor: { libraries?: LibrariesState }; +}): AllLibrariesData { + const libraries = state.editor.libraries; + const current = currentLibraries(libraries); + const loaded = retainedLibraries(libraries); + const error = retainedError(libraries); + if (!loaded && !error) { + return {}; + } + return { + allLibraries: loaded ?? [], + allLibrariesError: loaded ? undefined : error, + allLibrariesRefreshError: loaded && !current ? error : undefined, + }; +} + +/** + * Fetches the sitewide library list unless a copy is already loaded or a + * request is already in flight (the app header also fetches it on mount). + * A previously failed request is retried. A failure lands in Redux state; + * the catch only avoids an unhandled rejection. + */ +export function fetchLibrariesIfNeeded(dispatch, actions: ActionCreator): void { + dispatch( + ( + thunkDispatch, + getState: () => { editor: { libraries?: LibrariesState } } + ) => { + const libraries = getState().editor.libraries; + const inFlight = libraries?.isFetching; + const settledCleanly = + !!retainedLibraries(libraries) && !retainedError(libraries); + if (!inFlight && !settledCleanly) { + thunkDispatch(actions.fetchLibraries()).catch(() => {}); + } + } + ); +} + +// The predicates settled-ness is derived from, feeding both functions +// above. The two deliberately differ on one state: a failure beside a +// current list is nothing to report for settledAllLibraries, while +// fetchLibrariesIfNeeded still retries it to clear the stray fetchError. +// isLoaded is deliberately not consulted: actions from the shared +// EDIT_LIBRARY prefix can clear fetchError while leaving isLoaded true, +// and isLoaded alone proves neither a list nor an error worth showing. + +/** The current list, from data. */ +const currentLibraries = (libraries?: LibrariesState) => + libraries?.data?.libraries; + +/** The list in hand: the current one, or the copy retained during a refetch. */ +const retainedLibraries = (libraries?: LibrariesState) => + currentLibraries(libraries) ?? libraries?.lastData?.libraries; + +/** The failure in hand: the current one, or the one a retry is retrying. */ +const retainedError = (libraries?: LibrariesState) => + libraries?.fetchError ?? libraries?.lastFetchError ?? undefined; diff --git a/tests/jest/components/DiscoveryServices.test.tsx b/tests/jest/components/DiscoveryServices.test.tsx index 4b64177e9..5bbdf3599 100644 --- a/tests/jest/components/DiscoveryServices.test.tsx +++ b/tests/jest/components/DiscoveryServices.test.tsx @@ -52,6 +52,42 @@ describe("DiscoveryServices - registered library disclosure", () => { // ── Toggle visibility ───────────────────────────────────────────────────── + it("names registration data in the failed-library-list alert", () => { + // This tab's entries fall back to registration records, so the generic + // "shown by short name only" message would be wrong here. + const { container } = renderServices({ + discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any], + allLibraries: [], + allLibrariesError: { + status: 500, + response: "nope", + url: "/admin/libraries", + }, + libraryRegistrations: [ + { id: 1, libraries: [{ short_name: "alpha", status: "success" }] }, + ] as any, + }); + expect(container.querySelector(".alert-danger")).toHaveTextContent( + "Registered libraries are shown using registration data" + ); + }); + + it("shows no toggle while allLibraries has not settled", () => { + // Labels must render once, in final linked form, not flash bare short + // names that get rewritten when the sitewide list arrives. + const { container } = renderServices({ + discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any], + allLibraries: undefined, + libraryRegistrations: [ + { + id: 1, + libraries: [{ short_name: "alpha", status: "success" }], + }, + ] as any, + }); + expect(container.querySelector(".association-toggle")).toBeNull(); + }); + it("shows no toggle when libraryRegistrations data has not yet loaded", () => { const { container } = renderServices({ discovery_services: [{ id: 1, protocol: "p", name: "Service A" } as any], diff --git a/tests/jest/components/EditableConfigList.test.tsx b/tests/jest/components/EditableConfigList.test.tsx index 73bf1fa6d..05f4f1df8 100644 --- a/tests/jest/components/EditableConfigList.test.tsx +++ b/tests/jest/components/EditableConfigList.test.tsx @@ -23,6 +23,12 @@ describe("EditableConfigList - library association disclosure", () => { interface ServicesData { services: ServiceItem[]; allLibraries?: Array<{ short_name: string; name?: string; uuid?: string }>; + allLibrariesError?: { status: number; response: string; url: string }; + allLibrariesRefreshError?: { + status: number; + response: string; + url: string; + }; } class TestEditForm extends React.Component< @@ -36,6 +42,7 @@ describe("EditableConfigList - library association disclosure", () => { class TestServiceList extends EditableConfigList { EditForm = TestEditForm; listDataKey = "services"; + usesLibraryList = true; itemTypeName = "service"; urlBase = "/admin/services/"; identifierKey = "id"; @@ -377,13 +384,19 @@ describe("EditableConfigList - library association disclosure", () => { expect(items[1].textContent).toBe("Beta Library - beta"); }); - it("falls back to short_name when allLibraries is absent from the data", () => { + it("shows an alert and bare short names when the library list failed to load", () => { const { container } = renderWithContext( { />, config ); + expect(container.querySelector(".alert-danger")).toHaveTextContent( + "The library list failed to load" + ); + // The associations still render, by short name, below the alert. fireEvent.click(container.querySelector(".association-toggle")); expect(container.querySelector(".associated-items li").textContent).toBe( "nypl" ); }); + + it("shows a warning when the library list is stale", () => { + const { container } = renderWithContext( + , + config + ); + expect(container.querySelector(".alert-warning")).toHaveTextContent( + "The library list could not be refreshed" + ); + // The associations still render, from the retained list. + fireEvent.click(container.querySelector(".association-toggle")); + expect(container.querySelector(".associated-items li").textContent).toBe( + "NYPL - nypl" + ); + }); + + it("holds the association panel until allLibraries settles", () => { + const { container } = renderWithContext( + , + config + ); + // With the sitewide list unsettled, no toggle renders at all, so the + // panel cannot flash bare short names that get rewritten when the + // list arrives. The status line explains and announces the gap. + expect(container.querySelector(".association-toggle")).toBeNull(); + expect(container.querySelector('[role="status"]')).toHaveTextContent( + "Loading libraries..." + ); + }); }); }); diff --git a/tests/jest/components/IndividualAdminEditForm.test.tsx b/tests/jest/components/IndividualAdminEditForm.test.tsx index 4c73a49d2..181afa4da 100644 --- a/tests/jest/components/IndividualAdminEditForm.test.tsx +++ b/tests/jest/components/IndividualAdminEditForm.test.tsx @@ -202,6 +202,104 @@ describe("IndividualAdminEditForm - rendered inputs and role changes", () => { const roleCheckbox = (role: string) => screen.getByRole("checkbox", { name: roleNames[role] }); + const failureData = { + individualAdmins: [adminData], + allLibraries: [], + allLibrariesError: { + status: 500, + response: "nope", + url: "/admin/libraries", + }, + }; + + it("waits for the library list before showing per-library roles", () => { + const { container } = renderForm({ + item: adminData, + data: { individualAdmins: [adminData] }, + }); + expect(container.querySelector("table.library-admin-roles")).toBeNull(); + expect(container.querySelector('[role="status"]')).toHaveTextContent( + "Loading libraries..." + ); + // Sitewide toggles rewrite an existing admin's hidden per-library roles + // wholesale, so role edits are disabled until the list is available. + expect(roleCheckbox("system")).toBeDisabled(); + }); + + it("explains a failed library list load when editing an existing admin", () => { + // On failure allLibraries settles to [] and allLibrariesError is set. + const { container } = renderForm({ item: adminData, data: failureData }); + expect( + screen.getByText( + /library roles cannot be shown, and no roles can be changed/ + ) + ).toBeInTheDocument(); + // The role="alert" danger Alert announces the failure itself, so the + // status region empties rather than duplicating the announcement. + expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement(); + expect(roleCheckbox("system")).toBeDisabled(); + // All role edits are disabled, so the empty table shell is dropped. + expect(container.querySelector("table.library-admin-roles")).toBeNull(); + }); + + it("still allows sitewide roles for a new admin when the library list failed", () => { + // A new admin has no hidden per-library roles to clobber. + const { container } = renderForm({ data: failureData }); + expect( + screen.getByText(/Sitewide roles can still be assigned/) + ).toBeInTheDocument(); + expect(roleCheckbox("system")).toBeEnabled(); + expect(roleCheckbox("manager-all")).toBeEnabled(); + // No misleading "no libraries" row under a failure. + expect(container.querySelector("tbody")).toBeEmptyDOMElement(); + }); + + const staleData = { + individualAdmins: [adminData], + allLibraries, + allLibrariesRefreshError: { + status: 500, + response: "nope", + url: "/admin/libraries", + }, + }; + + it("locks only per-library roles for an existing admin while the list is stale", () => { + const { container } = renderForm({ item: adminData, data: staleData }); + expect(container.querySelector(".alert-warning")).toHaveTextContent( + "Per-library roles cannot be changed until the list can be refreshed." + ); + // The role="alert" warning announces the refresh failure itself, so + // the status region empties rather than duplicating the announcement. + expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement(); + // Only the per-library un-check branches expand sitewide roles from + // the (stale) list; sitewide toggles are list-independent and stay + // assignable, so submitting cannot be forced into a roleless admin. + expect(roleCheckbox("system")).toBeEnabled(); + expect(roleCheckbox("manager-all")).toBeEnabled(); + expect(roleCheckbox("manager-nypl")).toBeDisabled(); + expect(roleCheckbox("librarian-nypl")).toBeDisabled(); + }); + + it("locks only per-library roles for a new admin while the list is stale", () => { + const { container } = renderForm({ data: staleData }); + expect(container.querySelector(".alert-warning")).toHaveTextContent( + "Per-library roles cannot be changed" + ); + expect(roleCheckbox("system")).toBeEnabled(); + expect(roleCheckbox("manager-all")).toBeEnabled(); + expect(roleCheckbox("manager-nypl")).toBeDisabled(); + }); + + it("says when no libraries are configured", () => { + renderForm({ + data: { individualAdmins: [adminData], allLibraries: [] }, + }); + expect( + screen.getByText("No libraries are configured.") + ).toBeInTheDocument(); + }); + const expectRoles = (expected: string[]) => { for (const role of allRoles) { if (expected.includes(role)) { diff --git a/tests/jest/components/IndividualAdmins.test.tsx b/tests/jest/components/IndividualAdmins.test.tsx index 9a2406008..cd62f37f3 100644 --- a/tests/jest/components/IndividualAdmins.test.tsx +++ b/tests/jest/components/IndividualAdmins.test.tsx @@ -46,6 +46,84 @@ describe("IndividualAdmins - role association disclosure", () => { // ── Toggle visibility ───────────────────────────────────────────────────── + it("shows no toggle while allLibraries has not settled", () => { + // Labels must render once, in final linked form, not flash bare short + // names that get rewritten when the sitewide list arrives. + const { container } = renderWithContext( + , + sysAdminConfig + ); + expect(container.querySelector(".association-toggle")).toBeNull(); + }); + + it("shows the sysadmin entry before allLibraries settles", () => { + // The synthetic sysadmin entry never consults the library list and can + // never be rewritten by it, so it is not held back. + const { container } = renderWithContext( + , + sysAdminConfig + ); + fireEvent.click(container.querySelector(".association-toggle")); + expect(container.querySelector(".associated-items li").textContent).toBe( + "sysadmin" + ); + }); + + it("names roles, not libraries, when the library list failed to load", () => { + const { container } = renderWithContext( + , + sysAdminConfig + ); + expect(container.querySelector(".alert-danger")).toHaveTextContent( + "Roles are shown by library short name only." + ); + }); + it("shows no toggle for an admin with no roles field", () => { const { container } = renderAdmins([{ email: "noroles@example.com" }]); expect(container.querySelector(".association-toggle")).toBeNull(); @@ -498,10 +576,13 @@ describe("IndividualAdmins - connect wiring", () => { afterEach(() => jest.restoreAllMocks()); it("renders the connected default export, fetching on mount", async () => { - jest.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ individualAdmins: [] }), { - headers: { "Content-Type": "application/json" }, - }) + // A Response body can only be read once, so build one per fetch call. + const fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ individualAdmins: [] }), { + headers: { "Content-Type": "application/json" }, + }) + ) ); renderWithProviders( @@ -517,5 +598,12 @@ describe("IndividualAdmins - connect wiring", () => { expect( await screen.findByText("Create new individual admin") ).toBeInTheDocument(); + + // Outside setup mode, fetchData also requests the libraries list (the + // settingUp half of that guard is pinned in SetupPage.test.tsx). + const urls = fetchSpy.mock.calls.map((call) => String(call[0])); + expect(urls).toEqual( + expect.arrayContaining([expect.stringContaining("/admin/libraries")]) + ); }); }); diff --git a/tests/jest/components/LibraryRegistration.test.tsx b/tests/jest/components/LibraryRegistration.test.tsx index 559f8bb05..45866967e 100644 --- a/tests/jest/components/LibraryRegistration.test.tsx +++ b/tests/jest/components/LibraryRegistration.test.tsx @@ -87,6 +87,24 @@ describe("LibraryRegistration", () => { expect(libraries(container)).toHaveLength(0); }); + it("explains instead of vanishing when the library list failed to load", () => { + const { container } = renderReg({ + item: serviceData, + data: makeData({ + allLibraries: [], + allLibrariesError: { + status: 500, + response: "nope", + url: "/admin/libraries", + }, + }), + }); + expect(libraries(container)).toHaveLength(0); + expect(container.querySelector(".alert-warning")).toHaveTextContent( + "Libraries cannot be registered: the library list is unavailable." + ); + }); + it("doesn't render libraries in edit form if protocol doesn't support registration", () => { const { container } = renderReg({ item: serviceData, diff --git a/tests/jest/components/PatronAuthServices.test.tsx b/tests/jest/components/PatronAuthServices.test.tsx index c33180e8f..c3dcd3065 100644 --- a/tests/jest/components/PatronAuthServices.test.tsx +++ b/tests/jest/components/PatronAuthServices.test.tsx @@ -77,6 +77,21 @@ describe("PatronAuthServices", () => { ).not.toBeInTheDocument(); }); + it("fetches the libraries list along with the services on mount", async () => { + const fetchSpy = stubFetch(listData); + renderConnected(); + + await waitFor(() => { + const urls = fetchSpy.mock.calls.map((call) => String(call[0])); + expect(urls).toEqual( + expect.arrayContaining([ + expect.stringContaining("/admin/patron_auth_services"), + expect.stringContaining("/admin/libraries"), + ]) + ); + }); + }); + it("shows the neighborhood analytics panel when creating a service whose protocol has a neighborhood_mode setting", async () => { const neighborhoodSetting = { key: "neighborhood_mode", diff --git a/tests/jest/components/ServiceEditForm.test.tsx b/tests/jest/components/ServiceEditForm.test.tsx index d2a9ed74c..f77689b75 100644 --- a/tests/jest/components/ServiceEditForm.test.tsx +++ b/tests/jest/components/ServiceEditForm.test.tsx @@ -484,6 +484,91 @@ describe("ServiceEditForm", () => { expect(container.querySelector(".with-edit-button a")).toBeNull(); }); + it("shows a loading indicator in the Libraries panel until allLibraries arrives", () => { + // Undefined allLibraries means the library list is still loading. + const dataStillLoading = Object.assign({}, servicesData, { + allLibraries: undefined, + }); + const { container, rerender } = renderForm({ + data: dataStillLoading, + item: serviceData, + }); + expect(container.querySelector(".update-libraries")).toBeNull(); + expect(container.querySelector('[role="status"]')).toHaveTextContent( + "Loading libraries..." + ); + + rerenderForm(rerender, { item: serviceData }); + // The status line stays mounted and announces completion; the + // completion text is visually hidden. + const status = container.querySelector('[role="status"]'); + expect(status).toHaveTextContent("Libraries loaded."); + expect(status).toHaveClass("visuallyHidden"); + const editable = container.querySelectorAll(".with-edit-button"); + expect(editable).toHaveLength(1); + expect(editable[0]).toHaveTextContent("New York Public Library - nypl"); + }); + + it("explains a failed library list load in the Libraries panel", () => { + // On failure allLibraries settles to [] and allLibrariesError is set. + const dataWithError = Object.assign({}, servicesData, { + allLibraries: [], + allLibrariesError: { + status: 500, + response: "nope", + url: "/admin/libraries", + }, + }); + const { container } = renderForm({ + data: dataWithError, + item: serviceData, + }); + expect(container.querySelector(".alert-danger")).toHaveTextContent( + "The library list failed to load" + ); + // The role="alert" danger Alert announces the failure itself, so the + // status region empties rather than duplicating the announcement. + expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement(); + // The associated library still renders, by short name. + const editable = container.querySelectorAll(".with-edit-button"); + expect(editable).toHaveLength(1); + expect(editable[0]).toHaveTextContent("nypl"); + // A removal could not be undone in-session, so it is disabled too. + expect(container.querySelector("button.remove-btn")).toBeDisabled(); + }); + + it("warns when the library list could not be refreshed", () => { + const staleData = Object.assign({}, servicesData, { + allLibrariesRefreshError: { + status: 500, + response: "nope", + url: "/admin/libraries", + }, + }); + const { container } = renderForm({ data: staleData, item: serviceData }); + expect(container.querySelector(".alert-warning")).toHaveTextContent( + "The library list could not be refreshed" + ); + // The role="alert" warning announces the refresh failure itself, so + // the status region empties rather than duplicating the announcement. + expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement(); + // The stale list still renders in full, links included, and the + // panel stays editable; only the blocking error disables removal. + const editable = container.querySelectorAll(".with-edit-button"); + expect(editable).toHaveLength(1); + expect(editable[0]).toHaveTextContent("New York Public Library - nypl"); + expect(container.querySelector(".alert-danger")).toBeNull(); + expect(container.querySelector("button.remove-btn")).toBeEnabled(); + }); + + it("says when no libraries are configured", () => { + const emptyData = Object.assign({}, servicesData, { allLibraries: [] }); + const { container } = renderForm({ data: emptyData }); + expect(container.querySelector(".update-libraries")).toHaveTextContent( + "No libraries are configured." + ); + }); + it("renders removable and editable libraries", () => { const { container, unmount } = renderForm(); expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0); diff --git a/tests/jest/components/SetupPage.test.tsx b/tests/jest/components/SetupPage.test.tsx index 58b5ef991..a1c9bbd55 100644 --- a/tests/jest/components/SetupPage.test.tsx +++ b/tests/jest/components/SetupPage.test.tsx @@ -6,13 +6,16 @@ import SetupPage from "../../../src/components/SetupPage"; describe("SetupPage", () => { beforeEach(() => { - // SetupPage renders the connected IndividualAdmins list, which fetches the - // admin list on mount. Stub fetch so mounting does not hit the network. - jest.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ individualAdmins: [] }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) + // SetupPage renders the connected IndividualAdmins list, which fetches on + // mount. Stub fetch so mounting does not hit the network; a Response body + // can only be read once, so build one per call. + jest.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ individualAdmins: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) ); }); @@ -37,5 +40,19 @@ describe("SetupPage", () => { name: "Set up your system admin account", }) ).toBeInTheDocument(); + + // settingUp skips the libraries request, which cannot succeed before an + // admin exists. The positive check keeps the negative one honest. + const urls = (globalThis.fetch as jest.Mock).mock.calls.map((call) => + String(call[0]) + ); + expect(urls).toEqual( + expect.arrayContaining([ + expect.stringContaining("/admin/individual_admins"), + ]) + ); + expect(urls).not.toEqual( + expect.arrayContaining([expect.stringContaining("/admin/libraries")]) + ); }); }); diff --git a/tests/jest/reducers/libraries.test.ts b/tests/jest/reducers/libraries.test.ts new file mode 100644 index 000000000..2f9766cff --- /dev/null +++ b/tests/jest/reducers/libraries.test.ts @@ -0,0 +1,171 @@ +import libraries from "../../../src/reducers/libraries"; +import ActionCreator from "../../../src/actions"; + +const REQUEST = `${ActionCreator.LIBRARIES}_${ActionCreator.REQUEST}`; +const FAILURE = `${ActionCreator.LIBRARIES}_${ActionCreator.FAILURE}`; +const SUCCESS = `${ActionCreator.LIBRARIES}_${ActionCreator.SUCCESS}`; +const LOAD = `${ActionCreator.LIBRARIES}_${ActionCreator.LOAD}`; +const EDIT_REQUEST = `${ActionCreator.EDIT_LIBRARY}_${ActionCreator.REQUEST}`; +const EDIT_SUCCESS = `${ActionCreator.EDIT_LIBRARY}_${ActionCreator.SUCCESS}`; + +describe("libraries reducer", () => { + const fetchError = { status: 500, response: "nope", url: "/admin/libraries" }; + + it("clears state on a first-load request", () => { + const state = libraries(undefined, { type: REQUEST }); + expect(state.isFetching).toBe(true); + expect(state.isLoaded).toBe(false); + expect(state.fetchError).toBeNull(); + }); + + it("moves the previous failure to lastFetchError while a retry is in flight", () => { + const failed = libraries(undefined, { type: FAILURE, error: fetchError }); + expect(failed.fetchError).toEqual(fetchError); + expect(failed.isLoaded).toBe(true); + + const retrying = libraries(failed, { type: REQUEST }); + expect(retrying.isFetching).toBe(true); + // fetchError keeps meaning "the current request failed"; the old + // failure moves to lastFetchError so consumers can keep showing it. + expect(retrying.fetchError).toBeNull(); + expect(retrying.lastFetchError).toEqual(fetchError); + expect(retrying.isLoaded).toBe(false); + }); + + it("retains the loaded list as lastData while a refetch is in flight", () => { + const data = { libraries: [{ short_name: "nypl" }] }; + let state = libraries(undefined, { type: LOAD, data }); + state = libraries(state, { type: REQUEST }); + + expect(state.isFetching).toBe(true); + // data keeps its normal lifecycle (nulled by REQUEST) for the Libraries + // config page; the retained copy moves to lastData. + expect(state.data).toBeNull(); + expect(state.isLoaded).toBe(false); + expect(state.lastData).toEqual(data); + }); + + it("keeps lastData through a failed refetch and clears it on a fresh load", () => { + const data = { libraries: [{ short_name: "nypl" }] }; + let state = libraries(undefined, { type: LOAD, data }); + state = libraries(state, { type: REQUEST }); + state = libraries(state, { type: FAILURE, error: fetchError }); + expect(state.lastData).toEqual(data); + expect(state.fetchError).toEqual(fetchError); + + state = libraries(state, { type: REQUEST }); + state = libraries(state, { type: SUCCESS }); + state = libraries(state, { type: LOAD, data }); + expect(state.lastData).toBeNull(); + expect(state.data).toEqual(data); + }); + + it("drops a failure recorded beside current data when a refetch starts", () => { + // Overlapping requests with mixed outcomes leave data plus fetchError. + // Consumers ignore that failure, so it must not resurface against the + // retained copy while the next refetch runs. + const data = { libraries: [{ short_name: "nypl" }] }; + let state = libraries(undefined, { type: LOAD, data }); + state = libraries(state, { type: FAILURE, error: fetchError }); + expect(state.data).toEqual(data); + expect(state.fetchError).toEqual(fetchError); + + state = libraries(state, { type: REQUEST }); + expect(state.lastData).toEqual(data); + expect(state.lastFetchError).toBeNull(); + }); + + it("keeps lastFetchError when a second request starts before the retry settles", () => { + // The header's fetch and the Libraries tab's fetch can overlap. + let state = libraries(undefined, { type: FAILURE, error: fetchError }); + state = libraries(state, { type: REQUEST }); + state = libraries(state, { type: REQUEST }); + + expect(state.fetchError).toBeNull(); + expect(state.lastFetchError).toEqual(fetchError); + }); + + it("keeps lastFetchError through a retry's SUCCESS until LOAD", () => { + let state = libraries(undefined, { type: FAILURE, error: fetchError }); + state = libraries(state, { type: REQUEST }); + state = libraries(state, { type: SUCCESS }); + + expect(state.data).toBeNull(); + expect(state.isLoaded).toBe(false); + expect(state.fetchError).toBeNull(); + expect(state.lastFetchError).toEqual(fetchError); + }); + + it("clears the old failure once a retry succeeds", () => { + const data = { libraries: [{ short_name: "nypl" }] }; + let state = libraries(undefined, { type: FAILURE, error: fetchError }); + state = libraries(state, { type: REQUEST }); + state = libraries(state, { type: SUCCESS }); + state = libraries(state, { type: LOAD, data }); + + expect(state.fetchError).toBeNull(); + expect(state.lastFetchError).toBeNull(); + expect(state.isLoaded).toBe(true); + expect(state.data).toEqual(data); + }); + + it("keeps a list-fetch failure when a library form submit starts", () => { + // EDIT_LIBRARY_REQUEST shares this reducer and clears fetchError; the + // failure must survive as lastFetchError so consumers do not mistake + // the state for a cleanly loaded list. + let state = libraries(undefined, { type: FAILURE, error: fetchError }); + state = libraries(state, { type: EDIT_REQUEST }); + + expect(state.fetchError).toBeNull(); + expect(state.lastFetchError).toEqual(fetchError); + }); + + it("keeps an already-retained failure when a library form submit starts", () => { + let state = libraries(undefined, { type: FAILURE, error: fetchError }); + state = libraries(state, { type: REQUEST }); + state = libraries(state, { type: EDIT_REQUEST }); + + expect(state.lastFetchError).toEqual(fetchError); + }); + + it("does not invent a failure when a library form submit starts cleanly", () => { + const state = libraries(undefined, { type: EDIT_REQUEST }); + expect(state.lastFetchError).toBeNull(); + }); + + it("keeps a list-fetch failure when a library form submit succeeds", () => { + // A list refetch can fail between EDIT_LIBRARY_REQUEST and its + // SUCCESS, and the base reducer's SUCCESS also clears fetchError. + let state = libraries(undefined, { type: REQUEST }); + state = libraries(state, { type: EDIT_REQUEST }); + state = libraries(state, { type: FAILURE, error: fetchError }); + state = libraries(state, { type: EDIT_SUCCESS }); + + expect(state.fetchError).toBeNull(); + expect(state.lastFetchError).toEqual(fetchError); + }); + + it("drops a failure beside current data when a library form submit starts", () => { + // Same rule as LIBRARIES_REQUEST: consumers ignore that failure. + const data = { libraries: [{ short_name: "nypl" }] }; + let state = libraries(undefined, { type: LOAD, data }); + state = libraries(state, { type: FAILURE, error: fetchError }); + state = libraries(state, { type: EDIT_REQUEST }); + + expect(state.lastFetchError).toBeNull(); + }); + + it("reports only the new failure when a retry fails again", () => { + const newError = { + status: 502, + response: "worse", + url: "/admin/libraries", + }; + let state = libraries(undefined, { type: FAILURE, error: fetchError }); + state = libraries(state, { type: REQUEST }); + state = libraries(state, { type: FAILURE, error: newError }); + + expect(state.fetchError).toEqual(newError); + expect(state.lastFetchError).toBeNull(); + }); +}); diff --git a/tests/jest/utils/allLibraries.test.ts b/tests/jest/utils/allLibraries.test.ts new file mode 100644 index 000000000..e14242c2b --- /dev/null +++ b/tests/jest/utils/allLibraries.test.ts @@ -0,0 +1,235 @@ +import { + fetchLibrariesIfNeeded, + settledAllLibraries, +} from "../../../src/utils/allLibraries"; + +describe("settledAllLibraries", () => { + const libraries = [{ short_name: "nypl", name: "New York Public Library" }]; + const stateWith = (librariesState) => ({ + editor: { libraries: librariesState }, + }); + + it("returns nothing while the request has not settled", () => { + expect(settledAllLibraries(stateWith(undefined))).toEqual({}); + expect( + settledAllLibraries( + stateWith({ data: null, isLoaded: false, isFetching: true }) + ) + ).toEqual({}); + }); + + it("reports not settled when isLoaded is set without a list or error", () => { + // EDIT_LIBRARY actions can clear fetchError while leaving isLoaded + // true; that state proves nothing worth showing. + expect(settledAllLibraries(stateWith({ isLoaded: true }))).toEqual({}); + }); + + it("settles an empty loaded list", () => { + expect( + settledAllLibraries( + stateWith({ data: { libraries: [] }, isLoaded: true }) + ) + ).toEqual({ + allLibraries: [], + allLibrariesError: undefined, + allLibrariesRefreshError: undefined, + }); + }); + + it("returns the loaded list", () => { + expect( + settledAllLibraries( + stateWith({ data: { libraries }, isLoaded: true, fetchError: null }) + ) + ).toEqual({ allLibraries: libraries, allLibrariesError: undefined }); + }); + + it("serves the retained list while a refetch is in flight", () => { + expect( + settledAllLibraries( + stateWith({ + data: null, + isLoaded: false, + isFetching: true, + lastData: { libraries }, + }) + ) + ).toEqual({ + allLibraries: libraries, + allLibrariesError: undefined, + allLibrariesRefreshError: undefined, + }); + }); + + it("serves the retained list plus a refresh error after a failed refetch", () => { + const fetchError = { + status: 500, + response: "nope", + url: "/admin/libraries", + }; + expect( + settledAllLibraries( + stateWith({ + data: null, + isLoaded: true, + fetchError, + lastData: { libraries }, + }) + ) + ).toEqual({ + allLibraries: libraries, + allLibrariesError: undefined, + allLibrariesRefreshError: fetchError, + }); + }); + + it("ignores a failure recorded beside a current list", () => { + // Overlapping requests with mixed outcomes (one LOAD, one FAILURE) can + // leave a current list next to a recorded failure. The list on screen + // is up to date, so no error of either kind is reported. + const fetchError = { + status: 500, + response: "nope", + url: "/admin/libraries", + }; + expect( + settledAllLibraries( + stateWith({ data: { libraries }, isLoaded: true, fetchError }) + ) + ).toEqual({ + allLibraries: libraries, + allLibrariesError: undefined, + allLibrariesRefreshError: undefined, + }); + }); + + it("settles to an empty list plus the error on failure", () => { + const fetchError = { + status: 500, + response: "nope", + url: "/admin/libraries", + }; + expect( + settledAllLibraries(stateWith({ data: null, isLoaded: true, fetchError })) + ).toEqual({ allLibraries: [], allLibrariesError: fetchError }); + }); + + it("keeps a previous failure settled while a retry is in flight", () => { + // The reducer moves the old failure to lastFetchError during a retry. + const lastFetchError = { + status: 500, + response: "nope", + url: "/admin/libraries", + }; + expect( + settledAllLibraries( + stateWith({ + data: null, + isLoaded: false, + isFetching: true, + fetchError: null, + lastFetchError, + }) + ) + ).toEqual({ allLibraries: [], allLibrariesError: lastFetchError }); + }); +}); + +describe("fetchLibrariesIfNeeded", () => { + const fetchThunk = "libraries thunk"; + const actions = { fetchLibraries: jest.fn(() => fetchThunk) } as any; + + // A dispatch that runs thunks against the given libraries state and + // records every other dispatched action. + const makeDispatch = (librariesState) => { + const dispatched = []; + const getState = () => ({ editor: { libraries: librariesState } }); + const dispatch = (action) => { + if (typeof action === "function") { + return action(dispatch, getState); + } + dispatched.push(action); + return Promise.resolve(); + }; + return { dispatch, dispatched }; + }; + + beforeEach(() => jest.clearAllMocks()); + + it("fetches when the list has never loaded", () => { + const { dispatch, dispatched } = makeDispatch(undefined); + fetchLibrariesIfNeeded(dispatch, actions); + expect(dispatched).toEqual([fetchThunk]); + }); + + it("does not fetch when the list is already loaded", () => { + const { dispatch, dispatched } = makeDispatch({ + data: { libraries: [] }, + isLoaded: true, + isFetching: false, + fetchError: null, + }); + fetchLibrariesIfNeeded(dispatch, actions); + expect(dispatched).toEqual([]); + }); + + it("fetches when isLoaded is set without a list or error", () => { + // EDIT_LIBRARY actions can clear fetchError while leaving isLoaded + // true; there is nothing to show, so fetch. + const { dispatch, dispatched } = makeDispatch({ + isLoaded: true, + isFetching: false, + fetchError: null, + }); + fetchLibrariesIfNeeded(dispatch, actions); + expect(dispatched).toEqual([fetchThunk]); + }); + + it("retries when a retained list carries a retained failure", () => { + const { dispatch, dispatched } = makeDispatch({ + data: null, + isLoaded: false, + isFetching: false, + fetchError: null, + lastData: { libraries: [] }, + lastFetchError: { + status: 500, + response: "nope", + url: "/admin/libraries", + }, + }); + fetchLibrariesIfNeeded(dispatch, actions); + expect(dispatched).toEqual([fetchThunk]); + }); + + it("does not fetch while a request is already in flight", () => { + const { dispatch, dispatched } = makeDispatch({ + isLoaded: false, + isFetching: true, + fetchError: null, + }); + fetchLibrariesIfNeeded(dispatch, actions); + expect(dispatched).toEqual([]); + }); + + it("retries when a loaded list has a recorded failure", () => { + const { dispatch, dispatched } = makeDispatch({ + data: { libraries: [] }, + isLoaded: true, + isFetching: false, + fetchError: { status: 500, response: "nope", url: "/admin/libraries" }, + }); + fetchLibrariesIfNeeded(dispatch, actions); + expect(dispatched).toEqual([fetchThunk]); + }); + + it("retries after a failed request", () => { + const { dispatch, dispatched } = makeDispatch({ + isLoaded: true, + isFetching: false, + fetchError: { status: 500, response: "nope", url: "/admin/libraries" }, + }); + fetchLibrariesIfNeeded(dispatch, actions); + expect(dispatched).toEqual([fetchThunk]); + }); +});