From 3317912edb97e47d491fef8796857e60f4a7121e Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 20 Feb 2026 11:27:27 -0400 Subject: [PATCH 1/3] Add collection import button to admin UI Add a CollectionImportButton component that renders a "Queue Import" button with a "Force full re-import" checkbox on the collection edit screen for protocols that support import. Wire it into the Collections page via context and connect it to the new backend import endpoint. --- src/actions.ts | 13 +- src/components/CollectionImportButton.tsx | 99 ++++++++++++ src/components/Collections.tsx | 40 +++++ src/components/__tests__/Collections-test.tsx | 6 + src/interfaces.ts | 1 + .../CollectionImportButton.test.tsx | 149 ++++++++++++++++++ 6 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 src/components/CollectionImportButton.tsx create mode 100644 tests/jest/components/CollectionImportButton.test.tsx diff --git a/src/actions.ts b/src/actions.ts index 8d41efc876..6c3822d582 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -66,6 +66,7 @@ export default class ActionCreator extends BaseActionCreator { static readonly COLLECTIONS = "COLLECTIONS"; static readonly EDIT_COLLECTION = "EDIT_COLLECTION"; static readonly DELETE_COLLECTION = "DELETE_COLLECTION"; + static readonly IMPORT_COLLECTION = "IMPORT_COLLECTION"; static readonly INDIVIDUAL_ADMINS = "INDIVIDUAL_ADMINS"; static readonly EDIT_INDIVIDUAL_ADMIN = "EDIT_INDIVIDUAL_ADMIN"; static readonly DELETE_INDIVIDUAL_ADMIN = "DELETE_INDIVIDUAL_ADMIN"; @@ -451,6 +452,13 @@ export default class ActionCreator extends BaseActionCreator { ).bind(this); } + importCollection(collectionId: string | number, force: boolean) { + const url = "/admin/collection/" + collectionId + "/import"; + const data = new FormData(); + data.append("force", String(force)); + return this.postForm(ActionCreator.IMPORT_COLLECTION, url, data).bind(this); + } + fetchIndividualAdmins() { const url = "/admin/individual_admins"; return this.fetchJSON( @@ -1002,14 +1010,15 @@ export default class ActionCreator extends BaseActionCreator { } fetchQuicksightEmbedUrl(dashboardId: string, ld: LibrariesData) { - /* Too many libraries will blow up the 8K cloudfront/nginx max url size limit. By not sending any uuids, the client will assemble a list of libraries based on the user's permissions. */ let library_uuids: string = ""; if (ld.libraries.length < 100) { - library_uuids = `?libraryUuids=${ld.libraries.map((l) => l.uuid).join(",")}`; + library_uuids = `?libraryUuids=${ld.libraries + .map((l) => l.uuid) + .join(",")}`; } const url = `/admin/quicksight_embed/${dashboardId}${library_uuids}`; return this.fetchJSON( diff --git a/src/components/CollectionImportButton.tsx b/src/components/CollectionImportButton.tsx new file mode 100644 index 0000000000..494877a48c --- /dev/null +++ b/src/components/CollectionImportButton.tsx @@ -0,0 +1,99 @@ +import * as React from "react"; +import { CollectionData, ProtocolData } from "../interfaces"; + +export interface CollectionImportButtonProps { + collection: CollectionData; + protocols: ProtocolData[]; + importCollection: ( + collectionId: string | number, + force: boolean + ) => Promise; + disabled?: boolean; +} + +interface CollectionImportButtonState { + force: boolean; + importing: boolean; + feedback: string | null; +} + +/** + * Renders an import button and "force" checkbox for collections + * whose protocol supports import. Renders nothing otherwise. + */ +export default class CollectionImportButton extends React.Component< + CollectionImportButtonProps, + CollectionImportButtonState +> { + constructor(props: CollectionImportButtonProps) { + super(props); + this.state = { + force: false, + importing: false, + feedback: null, + }; + this.handleImport = this.handleImport.bind(this); + this.handleForceChange = this.handleForceChange.bind(this); + } + + supportsImport(): boolean { + const { collection, protocols } = this.props; + if (!collection?.id || !collection?.protocol) { + return false; + } + const protocol = protocols.find((p) => p.name === collection.protocol); + return !!protocol?.supports_import; + } + + async handleImport(): Promise { + const { collection, importCollection } = this.props; + this.setState({ importing: true, feedback: null }); + try { + await importCollection(collection.id, this.state.force); + this.setState({ importing: false, feedback: "Import task queued." }); + } catch (e) { + const message = + e && typeof e === "object" && "response" in e + ? String((e as { response: string }).response) + : "Failed to queue import task."; + this.setState({ importing: false, feedback: message }); + } + } + + handleForceChange(e: React.ChangeEvent): void { + this.setState({ force: e.target.checked }); + } + + render(): JSX.Element | null { + if (!this.supportsImport()) { + return null; + } + + const { disabled } = this.props; + const { force, importing, feedback } = this.state; + + return ( +
+

Import

+ +
+ + {feedback &&

{feedback}

} +
+ ); + } +} diff --git a/src/components/Collections.tsx b/src/components/Collections.tsx index ad0fb5bec7..f7bc421a8e 100644 --- a/src/components/Collections.tsx +++ b/src/components/Collections.tsx @@ -15,6 +15,7 @@ import { LibraryWithSettingsData, LibraryRegistrationsData, } from "../interfaces"; +import CollectionImportButton from "./CollectionImportButton"; import ServiceWithRegistrationsEditForm from "./ServiceWithRegistrationsEditForm"; import TrashIcon from "./icons/TrashIcon"; @@ -27,6 +28,10 @@ export interface CollectionsDispatchProps extends EditableConfigListDispatchProps { registerLibrary: (data: FormData) => Promise; fetchLibraryRegistrations?: () => Promise; + importCollection: ( + collectionId: string | number, + force: boolean + ) => Promise; } export interface CollectionsProps @@ -37,6 +42,19 @@ export interface CollectionsProps export class CollectionEditForm extends ServiceWithRegistrationsEditForm< CollectionsData > { + context: { + registerLibrary: (library, registration_stage) => void; + importCollection: ( + collectionId: string | number, + force: boolean + ) => Promise; + }; + + static contextTypes = { + ...ServiceWithRegistrationsEditForm.contextTypes, + importCollection: PropTypes.func, + }; + /** * Override to display a confirmation message before removing a library * association. We display the confirmation and return `true` if the @@ -51,6 +69,22 @@ export class CollectionEditForm extends ServiceWithRegistrationsEditForm< "remove all loans and holds for its patrons. Do you wish to continue?"; return window.confirm(confirmationMessage); } + + render(): JSX.Element { + return ( +
+ {super.render()} + {this.props.item?.id && ( + + )} +
+ ); + } } /** @@ -78,6 +112,7 @@ export class Collections extends GenericEditableConfigList< static childContextTypes: React.ValidationMap = { registerLibrary: PropTypes.func, + importCollection: PropTypes.func, }; getChildContext() { @@ -94,6 +129,9 @@ export class Collections extends GenericEditableConfigList< }); } }, + importCollection: (collectionId: string | number, force: boolean) => { + return this.props.importCollection(collectionId, force); + }, }; } @@ -174,6 +212,8 @@ function mapDispatchToProps(dispatch, ownProps) { editItem: (data: FormData) => dispatch(actions.editCollection(data)), deleteItem: (identifier: string | number) => dispatch(actions.deleteCollection(identifier)), + importCollection: (collectionId: string | number, force: boolean) => + dispatch(actions.importCollection(collectionId, force)), }; } diff --git a/src/components/__tests__/Collections-test.tsx b/src/components/__tests__/Collections-test.tsx index 897e2a8025..908e046b7b 100644 --- a/src/components/__tests__/Collections-test.tsx +++ b/src/components/__tests__/Collections-test.tsx @@ -54,6 +54,9 @@ describe("Collections", () => { identifier="2" registerLibrary={registerLibrary} fetchLibraryRegistrations={fetchLibraryRegistrations} + importCollection={stub().returns( + new Promise((resolve) => resolve()) + )} />, { context: { admin: systemAdmin } } ); @@ -92,6 +95,9 @@ describe("Collections", () => { data={{ collections, protocols: [] }} registerLibrary={registerLibrary} fetchLibraryRegistrations={fetchLibraryRegistrations} + importCollection={stub().returns( + new Promise((resolve) => resolve()) + )} />, { context: { admin: systemAdmin } } ); diff --git a/src/interfaces.ts b/src/interfaces.ts index 5d6ff2064f..64c9f9aaae 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -329,6 +329,7 @@ export interface ProtocolData { sitewide?: boolean; supports_registration?: boolean; supports_staging?: boolean; + supports_import?: boolean; settings: SettingData[]; child_settings?: SettingData[]; library_settings?: SettingData[]; diff --git a/tests/jest/components/CollectionImportButton.test.tsx b/tests/jest/components/CollectionImportButton.test.tsx new file mode 100644 index 0000000000..d33b5fabe9 --- /dev/null +++ b/tests/jest/components/CollectionImportButton.test.tsx @@ -0,0 +1,149 @@ +import * as React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CollectionImportButton, { + CollectionImportButtonProps, +} from "../../../src/components/CollectionImportButton"; +import { CollectionData, ProtocolData } from "../../../src/interfaces"; + +const protocolWithImport: ProtocolData = { + name: "Boundless", + label: "Boundless", + supports_import: true, + settings: [], +}; + +const protocolWithoutImport: ProtocolData = { + name: "Overdrive", + label: "Overdrive", + supports_import: false, + settings: [], +}; + +const savedCollection: CollectionData = { + id: 42, + protocol: "Boundless", + name: "Test Collection", +}; + +const unsavedCollection: CollectionData = { + protocol: "Boundless", + name: "Unsaved Collection", +}; + +function renderButton(overrides: Partial = {}) { + const defaultProps: CollectionImportButtonProps = { + collection: savedCollection, + protocols: [protocolWithImport, protocolWithoutImport], + importCollection: jest.fn().mockResolvedValue(undefined), + disabled: false, + ...overrides, + }; + return { + ...render(), + importCollection: defaultProps.importCollection as jest.Mock, + }; +} + +describe("CollectionImportButton", () => { + it("does not render when protocol lacks supports_import", () => { + const collection: CollectionData = { + id: 1, + protocol: "Overdrive", + name: "OD Collection", + }; + const { container } = renderButton({ collection }); + expect(container.innerHTML).toBe(""); + }); + + it("does not render for unsaved collection (no id)", () => { + const { container } = renderButton({ collection: unsavedCollection }); + expect(container.innerHTML).toBe(""); + }); + + it("renders button and checkbox when supported", () => { + renderButton(); + expect( + screen.getByRole("button", { name: "Queue Import" }) + ).toBeInTheDocument(); + expect(screen.getByRole("checkbox")).toBeInTheDocument(); + expect(screen.getByText("Force full re-import")).toBeInTheDocument(); + }); + + it("checkbox toggles force state", async () => { + const user = userEvent.setup(); + renderButton(); + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + expect(checkbox).toBeChecked(); + await user.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + it("button triggers import with correct args (force=false)", async () => { + const user = userEvent.setup(); + const { importCollection } = renderButton(); + const button = screen.getByRole("button", { name: "Queue Import" }); + await user.click(button); + expect(importCollection).toHaveBeenCalledWith(42, false); + }); + + it("button triggers import with force=true when checked", async () => { + const user = userEvent.setup(); + const { importCollection } = renderButton(); + const checkbox = screen.getByRole("checkbox"); + await user.click(checkbox); + const button = screen.getByRole("button", { name: "Queue Import" }); + await user.click(button); + expect(importCollection).toHaveBeenCalledWith(42, true); + }); + + it("shows success feedback after import", async () => { + const user = userEvent.setup(); + renderButton(); + await user.click(screen.getByRole("button", { name: "Queue Import" })); + await waitFor(() => { + expect(screen.getByText("Import task queued.")).toBeInTheDocument(); + }); + }); + + it("shows error feedback on failure", async () => { + const user = userEvent.setup(); + const mockImport = jest + .fn() + .mockRejectedValue({ response: "Something went wrong" }); + renderButton({ importCollection: mockImport }); + await user.click(screen.getByRole("button", { name: "Queue Import" })); + await waitFor(() => { + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + }); + + it("disables button and checkbox when disabled prop is true", () => { + renderButton({ disabled: true }); + expect(screen.getByRole("button", { name: "Queue Import" })).toBeDisabled(); + expect(screen.getByRole("checkbox")).toBeDisabled(); + }); + + it("shows 'Queuing...' text while importing", async () => { + const user = userEvent.setup(); + let resolveImport: () => void; + const pendingImport = new Promise((resolve) => { + resolveImport = resolve; + }); + const mockImport = jest.fn().mockReturnValue(pendingImport); + renderButton({ importCollection: mockImport }); + + await user.click(screen.getByRole("button", { name: "Queue Import" })); + + expect(screen.getByRole("button", { name: "Queuing..." })).toBeDisabled(); + + resolveImport(); + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Queue Import" }) + ).toBeEnabled(); + }); + }); +}); From c9542bfce19e9dbb066e18c8876ff9dbf765b33f Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 20 Feb 2026 11:48:55 -0400 Subject: [PATCH 2/3] Reset collection import UI state when switching collections --- src/components/CollectionImportButton.tsx | 10 ++++++ .../CollectionImportButton.test.tsx | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/components/CollectionImportButton.tsx b/src/components/CollectionImportButton.tsx index 494877a48c..1ec66a6deb 100644 --- a/src/components/CollectionImportButton.tsx +++ b/src/components/CollectionImportButton.tsx @@ -36,6 +36,16 @@ export default class CollectionImportButton extends React.Component< this.handleForceChange = this.handleForceChange.bind(this); } + componentDidUpdate(prevProps: CollectionImportButtonProps): void { + if (prevProps.collection?.id !== this.props.collection?.id) { + this.setState({ + force: false, + importing: false, + feedback: null, + }); + } + } + supportsImport(): boolean { const { collection, protocols } = this.props; if (!collection?.id || !collection?.protocol) { diff --git a/tests/jest/components/CollectionImportButton.test.tsx b/tests/jest/components/CollectionImportButton.test.tsx index d33b5fabe9..ca8534b678 100644 --- a/tests/jest/components/CollectionImportButton.test.tsx +++ b/tests/jest/components/CollectionImportButton.test.tsx @@ -120,6 +120,39 @@ describe("CollectionImportButton", () => { }); }); + it("resets force checkbox and feedback when switching collections", async () => { + const user = userEvent.setup(); + const { rerender, importCollection } = renderButton(); + + const checkbox = screen.getByRole("checkbox"); + await user.click(checkbox); + expect(checkbox).toBeChecked(); + + await user.click(screen.getByRole("button", { name: "Queue Import" })); + await waitFor(() => { + expect(screen.getByText("Import task queued.")).toBeInTheDocument(); + }); + + const nextCollection: CollectionData = { + id: 99, + protocol: "Boundless", + name: "Another Collection", + }; + rerender( + + ); + + await waitFor(() => { + expect(screen.getByRole("checkbox")).not.toBeChecked(); + expect(screen.queryByText("Import task queued.")).not.toBeInTheDocument(); + }); + }); + it("disables button and checkbox when disabled prop is true", () => { renderButton({ disabled: true }); expect(screen.getByRole("button", { name: "Queue Import" })).toBeDisabled(); From 674086fba8c13c3554422e38ad276913e9b8b406 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Fri, 20 Feb 2026 13:49:40 -0400 Subject: [PATCH 3/3] Improve collection import UI with Panel layout and success/error styling Wrap the import controls in a collapsible Panel, add alert-success and alert-danger feedback styling, and use renderAdditionalContent hook on ServiceEditForm to inject the import button cleanly. --- src/components/CollectionImportButton.tsx | 63 ++++++++++++------- src/components/Collections.tsx | 27 ++++---- src/components/ServiceEditForm.tsx | 7 +++ .../CollectionImportButton.test.tsx | 37 +++++++++-- 4 files changed, 92 insertions(+), 42 deletions(-) diff --git a/src/components/CollectionImportButton.tsx b/src/components/CollectionImportButton.tsx index 1ec66a6deb..8b5cc0a966 100644 --- a/src/components/CollectionImportButton.tsx +++ b/src/components/CollectionImportButton.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { Panel } from "library-simplified-reusable-components"; import { CollectionData, ProtocolData } from "../interfaces"; export interface CollectionImportButtonProps { @@ -15,6 +16,7 @@ interface CollectionImportButtonState { force: boolean; importing: boolean; feedback: string | null; + success: boolean; } /** @@ -31,6 +33,7 @@ export default class CollectionImportButton extends React.Component< force: false, importing: false, feedback: null, + success: false, }; this.handleImport = this.handleImport.bind(this); this.handleForceChange = this.handleForceChange.bind(this); @@ -42,6 +45,7 @@ export default class CollectionImportButton extends React.Component< force: false, importing: false, feedback: null, + success: false, }); } } @@ -60,13 +64,17 @@ export default class CollectionImportButton extends React.Component< this.setState({ importing: true, feedback: null }); try { await importCollection(collection.id, this.state.force); - this.setState({ importing: false, feedback: "Import task queued." }); + this.setState({ + importing: false, + feedback: "Import task queued.", + success: true, + }); } catch (e) { const message = e && typeof e === "object" && "response" in e ? String((e as { response: string }).response) : "Failed to queue import task."; - this.setState({ importing: false, feedback: message }); + this.setState({ importing: false, feedback: message, success: false }); } } @@ -80,30 +88,41 @@ export default class CollectionImportButton extends React.Component< } const { disabled } = this.props; - const { force, importing, feedback } = this.state; + const { force, importing, feedback, success } = this.state; + const feedbackClass = success + ? "alert alert-success" + : "alert alert-danger"; - return ( + const panelContent = (
-

Import

-
} +
+ - {feedback &&

{feedback}

} + onClick={this.handleImport} + > + {importing ? "Queuing..." : "Queue Import"} + + +
); + + return ( + + ); } } diff --git a/src/components/Collections.tsx b/src/components/Collections.tsx index f7bc421a8e..f68739222d 100644 --- a/src/components/Collections.tsx +++ b/src/components/Collections.tsx @@ -70,20 +70,19 @@ export class CollectionEditForm extends ServiceWithRegistrationsEditForm< return window.confirm(confirmationMessage); } - render(): JSX.Element { - return ( -
- {super.render()} - {this.props.item?.id && ( - - )} -
- ); + renderAdditionalContent(): React.ReactNode[] { + if (!this.props.item?.id) { + return []; + } + return [ + , + ]; } } diff --git a/src/components/ServiceEditForm.tsx b/src/components/ServiceEditForm.tsx index 9f97e6ce46..c49203051b 100644 --- a/src/components/ServiceEditForm.tsx +++ b/src/components/ServiceEditForm.tsx @@ -215,11 +215,18 @@ export default class ServiceEditForm< optionalFieldsPanel, extraPanel, librariesForm, + ...this.renderAdditionalContent(), ]} /> ); } + /** Hook for subclasses to inject additional panels into the form content, + * rendered after the libraries panel but before the submit button. */ + renderAdditionalContent(): React.ReactNode[] { + return []; + } + renderRequiredFields( requiredFields, protocol: ProtocolData, diff --git a/tests/jest/components/CollectionImportButton.test.tsx b/tests/jest/components/CollectionImportButton.test.tsx index ca8534b678..f08be022a0 100644 --- a/tests/jest/components/CollectionImportButton.test.tsx +++ b/tests/jest/components/CollectionImportButton.test.tsx @@ -45,6 +45,11 @@ function renderButton(overrides: Partial = {}) { }; } +/** Expand the collapsed Import panel by clicking its header. */ +async function expandPanel(user: ReturnType) { + await user.click(screen.getByText("Import")); +} + describe("CollectionImportButton", () => { it("does not render when protocol lacks supports_import", () => { const collection: CollectionData = { @@ -61,8 +66,15 @@ describe("CollectionImportButton", () => { expect(container.innerHTML).toBe(""); }); - it("renders button and checkbox when supported", () => { + it("renders panel header when supported", () => { + renderButton(); + expect(screen.getByText("Import")).toBeInTheDocument(); + }); + + it("renders button and checkbox when panel is expanded", async () => { + const user = userEvent.setup(); renderButton(); + await expandPanel(user); expect( screen.getByRole("button", { name: "Queue Import" }) ).toBeInTheDocument(); @@ -73,6 +85,7 @@ describe("CollectionImportButton", () => { it("checkbox toggles force state", async () => { const user = userEvent.setup(); renderButton(); + await expandPanel(user); const checkbox = screen.getByRole("checkbox"); expect(checkbox).not.toBeChecked(); await user.click(checkbox); @@ -84,6 +97,7 @@ describe("CollectionImportButton", () => { it("button triggers import with correct args (force=false)", async () => { const user = userEvent.setup(); const { importCollection } = renderButton(); + await expandPanel(user); const button = screen.getByRole("button", { name: "Queue Import" }); await user.click(button); expect(importCollection).toHaveBeenCalledWith(42, false); @@ -92,6 +106,7 @@ describe("CollectionImportButton", () => { it("button triggers import with force=true when checked", async () => { const user = userEvent.setup(); const { importCollection } = renderButton(); + await expandPanel(user); const checkbox = screen.getByRole("checkbox"); await user.click(checkbox); const button = screen.getByRole("button", { name: "Queue Import" }); @@ -99,30 +114,37 @@ describe("CollectionImportButton", () => { expect(importCollection).toHaveBeenCalledWith(42, true); }); - it("shows success feedback after import", async () => { + it("shows success feedback with alert-success styling after import", async () => { const user = userEvent.setup(); renderButton(); + await expandPanel(user); await user.click(screen.getByRole("button", { name: "Queue Import" })); await waitFor(() => { - expect(screen.getByText("Import task queued.")).toBeInTheDocument(); + const feedback = screen.getByText("Import task queued."); + expect(feedback).toBeInTheDocument(); + expect(feedback).toHaveClass("alert", "alert-success"); }); }); - it("shows error feedback on failure", async () => { + it("shows error feedback with alert-danger styling on failure", async () => { const user = userEvent.setup(); const mockImport = jest .fn() .mockRejectedValue({ response: "Something went wrong" }); renderButton({ importCollection: mockImport }); + await expandPanel(user); await user.click(screen.getByRole("button", { name: "Queue Import" })); await waitFor(() => { - expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + const feedback = screen.getByText("Something went wrong"); + expect(feedback).toBeInTheDocument(); + expect(feedback).toHaveClass("alert", "alert-danger"); }); }); it("resets force checkbox and feedback when switching collections", async () => { const user = userEvent.setup(); const { rerender, importCollection } = renderButton(); + await expandPanel(user); const checkbox = screen.getByRole("checkbox"); await user.click(checkbox); @@ -153,8 +175,10 @@ describe("CollectionImportButton", () => { }); }); - it("disables button and checkbox when disabled prop is true", () => { + it("disables button and checkbox when disabled prop is true", async () => { + const user = userEvent.setup(); renderButton({ disabled: true }); + await expandPanel(user); expect(screen.getByRole("button", { name: "Queue Import" })).toBeDisabled(); expect(screen.getByRole("checkbox")).toBeDisabled(); }); @@ -167,6 +191,7 @@ describe("CollectionImportButton", () => { }); const mockImport = jest.fn().mockReturnValue(pendingImport); renderButton({ importCollection: mockImport }); + await expandPanel(user); await user.click(screen.getByRole("button", { name: "Queue Import" }));