@thepalaceproject/circulation-admin 1.43.0 → 1.44.0-post.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CLAUDE.md +28 -7
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/eslint.config.mjs +9 -1
  5. package/jest.config.js +22 -8
  6. package/package.json +6 -20
  7. package/tests/jest/actions.test.ts +962 -0
  8. package/tests/jest/components/AccountPage.test.tsx +41 -0
  9. package/tests/jest/components/AdvancedSearchBooleanFilter.test.tsx +231 -0
  10. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +93 -20
  11. package/tests/jest/components/AdvancedSearchFilter.test.tsx +142 -0
  12. package/tests/jest/components/AdvancedSearchFilterInput.test.tsx +212 -0
  13. package/tests/jest/components/AdvancedSearchFilterViewer.test.tsx +52 -0
  14. package/tests/jest/components/AdvancedSearchValueFilter.test.tsx +123 -0
  15. package/tests/jest/components/Announcement.test.tsx +76 -0
  16. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  17. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  18. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  19. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  20. package/tests/jest/components/BookDetails.test.tsx +133 -0
  21. package/tests/jest/components/BookDetailsContainer.test.tsx +116 -0
  22. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  23. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  24. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  25. package/tests/jest/components/BookEditor.test.tsx +20 -21
  26. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  27. package/tests/jest/components/CatalogServices.test.tsx +100 -0
  28. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  29. package/tests/jest/components/CirculationEventsDownloadForm.test.tsx +41 -0
  30. package/tests/jest/components/Classifications.test.tsx +166 -0
  31. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  32. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  33. package/tests/jest/components/Collections.test.tsx +335 -7
  34. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  35. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  36. package/tests/jest/components/Complaints.test.tsx +259 -0
  37. package/tests/jest/components/ConfigPage.test.tsx +58 -0
  38. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  39. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  40. package/tests/jest/components/Contributors.test.tsx +157 -0
  41. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  42. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  43. package/tests/jest/components/CustomListPage.test.tsx +57 -0
  44. package/tests/jest/components/CustomListSearch.test.tsx +275 -0
  45. package/tests/jest/components/CustomLists.test.tsx +621 -4
  46. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  47. package/tests/jest/components/CustomListsSidebar.test.tsx +251 -0
  48. package/tests/jest/components/DashboardPage.test.tsx +76 -0
  49. package/tests/jest/components/DebugAuthentication.test.tsx +1 -5
  50. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  51. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  52. package/tests/jest/components/DiagnosticsTabContainer.test.tsx +147 -0
  53. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  54. package/tests/jest/components/EditableConfigList.test.tsx +606 -9
  55. package/tests/jest/components/EditableInput.test.tsx +250 -1
  56. package/tests/jest/components/EditorField.test.tsx +95 -0
  57. package/tests/jest/components/EntryPointsContainer.test.tsx +97 -0
  58. package/tests/jest/components/EntryPointsTabs.test.tsx +88 -0
  59. package/tests/jest/components/ErrorMessage.test.tsx +97 -0
  60. package/tests/jest/components/Footer.test.tsx +141 -0
  61. package/tests/jest/components/GenreForm.test.tsx +152 -0
  62. package/tests/jest/components/Header.test.tsx +421 -0
  63. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  64. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  65. package/tests/jest/components/InputList.test.tsx +493 -0
  66. package/tests/jest/components/InventoryReportRequestModal.test.tsx +16 -42
  67. package/tests/jest/components/JsonField.test.tsx +20 -0
  68. package/tests/jest/components/Lane.test.tsx +132 -10
  69. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  70. package/tests/jest/components/LaneEditor.test.tsx +377 -13
  71. package/tests/jest/components/LanePage.test.tsx +68 -0
  72. package/tests/jest/components/Lanes.test.tsx +446 -0
  73. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  74. package/tests/jest/components/LanguageField.test.tsx +40 -0
  75. package/tests/jest/components/Libraries.test.tsx +48 -0
  76. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  77. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  78. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  79. package/tests/jest/components/LoadButton.test.tsx +49 -0
  80. package/tests/jest/components/ManagePatrons.test.tsx +73 -0
  81. package/tests/jest/components/ManagePatronsForm.test.tsx +180 -0
  82. package/tests/jest/components/ManagePatronsTabContainer.test.tsx +143 -0
  83. package/tests/jest/components/MetadataServices.test.tsx +95 -0
  84. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  85. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  86. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  87. package/tests/jest/components/PatronBlockingRulesEditor.test.tsx +3 -1
  88. package/tests/jest/components/PatronInfo.test.tsx +54 -0
  89. package/tests/jest/components/ProtocolFormField.test.tsx +479 -7
  90. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  91. package/tests/jest/components/SaveButton.test.tsx +47 -0
  92. package/tests/jest/components/SelfTestResult.test.tsx +164 -0
  93. package/tests/jest/components/SelfTests.test.tsx +601 -0
  94. package/tests/jest/components/SelfTestsCategory.test.tsx +200 -0
  95. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  96. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  97. package/tests/jest/components/ServiceWithRegistrationsEditForm.test.tsx +110 -0
  98. package/tests/jest/components/SetupPage.test.tsx +41 -0
  99. package/tests/jest/components/SingleStatListItem.test.tsx +62 -0
  100. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  101. package/tests/jest/components/Stats.test.tsx +4 -15
  102. package/tests/jest/components/TabContainer.test.tsx +68 -0
  103. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  104. package/tests/jest/components/Timestamp.test.tsx +74 -0
  105. package/tests/jest/components/ToolTip.test.tsx +46 -0
  106. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  107. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  108. package/tests/jest/components/TroubleshootingTabContainer.test.tsx +68 -0
  109. package/tests/jest/components/UpdatingLoader.test.tsx +36 -0
  110. package/tests/jest/components/WelcomePage.test.tsx +43 -0
  111. package/tests/jest/components/WithEditButton.test.tsx +55 -0
  112. package/tests/jest/components/WithRemoveButton.test.tsx +95 -0
  113. package/tests/jest/editorAdapter.test.ts +245 -0
  114. package/tests/jest/features/book.test.ts +75 -80
  115. package/tests/jest/fixtures/classificationsData.ts +45 -0
  116. package/tests/jest/fixtures/genreData.ts +811 -0
  117. package/tests/jest/index.test.tsx +50 -0
  118. package/tests/jest/models/Admin.test.ts +206 -0
  119. package/tests/jest/reducers/bookCoverPreview.test.ts +81 -0
  120. package/tests/jest/reducers/classifications.test.ts +161 -0
  121. package/tests/jest/reducers/collections.test.ts +96 -0
  122. package/tests/jest/reducers/complaints.test.ts +166 -0
  123. package/tests/jest/reducers/createFetchEditReducer.test.ts +220 -0
  124. package/tests/jest/reducers/createRegisterLibraryReducer.test.ts +51 -0
  125. package/tests/jest/reducers/customListDetails.test.ts +120 -0
  126. package/tests/jest/reducers/customListEditor.test.ts +2823 -0
  127. package/tests/jest/reducers/managePatrons.test.ts +71 -0
  128. package/tests/jest/testUtils/formDataShim.test.ts +135 -0
  129. package/tests/jest/testUtils/formDataShim.ts +112 -0
  130. package/tests/jest/utils/sharedFunctions.test.ts +121 -0
  131. package/tsconfig.json +3 -4
  132. package/testReporter.js +0 -29
  133. package/tests/jest/README.md +0 -6
@@ -0,0 +1,146 @@
1
+ import * as React from "react";
2
+ import { render, screen, within } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import AnnouncementsSection from "../../../src/components/AnnouncementsSection";
6
+ import { SettingData } from "../../../src/interfaces";
7
+
8
+ const announcements = [
9
+ {
10
+ content: "First Announcement",
11
+ start: "2020-05-12",
12
+ finish: "2020-06-12",
13
+ id: "perm_1",
14
+ },
15
+ {
16
+ content: "Second Announcement",
17
+ start: "2020-05-28",
18
+ finish: "2020-06-28",
19
+ id: "perm_2",
20
+ },
21
+ ];
22
+
23
+ const setting: SettingData = {
24
+ description: "announcements",
25
+ format: "date-range",
26
+ key: "announcements",
27
+ label: "Announcements",
28
+ type: "list",
29
+ };
30
+
31
+ const renderSection = (ref?: React.RefObject<AnnouncementsSection>) =>
32
+ render(
33
+ <AnnouncementsSection
34
+ ref={ref}
35
+ setting={setting}
36
+ // Fresh copies so the in-place sort during render can't leak between tests.
37
+ value={announcements.map((a) => ({ ...a }))}
38
+ />
39
+ );
40
+
41
+ const announcementEls = (container: HTMLElement) =>
42
+ Array.from(container.querySelectorAll<HTMLElement>(".announcement"));
43
+
44
+ describe("AnnouncementsSection", () => {
45
+ afterEach(() => {
46
+ jest.restoreAllMocks();
47
+ });
48
+
49
+ it("renders a list of announcements", () => {
50
+ const { container } = renderSection();
51
+
52
+ expect(
53
+ screen.getByRole("heading", { name: "Scheduled Announcements:" })
54
+ ).toBeInTheDocument();
55
+
56
+ const items = announcementEls(container);
57
+ expect(items).toHaveLength(2);
58
+
59
+ // Sorted by start date: First (05/12) then Second (05/28).
60
+ expect(
61
+ within(items[0]).getByText("First Announcement")
62
+ ).toBeInTheDocument();
63
+ expect(
64
+ within(items[0]).getByText("05/12/2020 – 06/12/2020")
65
+ ).toBeInTheDocument();
66
+ expect(
67
+ within(items[1]).getByText("Second Announcement")
68
+ ).toBeInTheDocument();
69
+ expect(
70
+ within(items[1]).getByText("05/28/2020 – 06/28/2020")
71
+ ).toBeInTheDocument();
72
+ });
73
+
74
+ it("renders a form", () => {
75
+ const { container } = renderSection();
76
+ expect(container.querySelectorAll(".announcement-form")).toHaveLength(1);
77
+ });
78
+
79
+ it("adds an announcement", async () => {
80
+ const user = userEvent.setup();
81
+ const { container } = renderSection();
82
+ expect(announcementEls(container)).toHaveLength(2);
83
+
84
+ // The only textbox on the page is the form's content field.
85
+ await user.type(screen.getByRole("textbox"), "Third Announcement");
86
+ await user.click(screen.getByRole("button", { name: "Add" }));
87
+
88
+ expect(announcementEls(container)).toHaveLength(3);
89
+ expect(screen.getByText("Third Announcement")).toBeInTheDocument();
90
+ });
91
+
92
+ it("edits an announcement", async () => {
93
+ const user = userEvent.setup();
94
+ const { container } = renderSection();
95
+ expect(announcementEls(container)).toHaveLength(2);
96
+
97
+ // Each announcement has its own Edit button; edit the first one.
98
+ await user.click(screen.getAllByRole("button", { name: "Edit" })[0]);
99
+
100
+ // The edited announcement leaves the list and is loaded into the form.
101
+ const items = announcementEls(container);
102
+ expect(items).toHaveLength(1);
103
+ expect(
104
+ within(items[0]).getByText("Second Announcement")
105
+ ).toBeInTheDocument();
106
+ expect(screen.getByRole("textbox")).toHaveValue("First Announcement");
107
+
108
+ // Its dates are loaded too, which is what lets it be re-saved unchanged.
109
+ const dates =
110
+ container.querySelectorAll<HTMLInputElement>('input[type="date"]');
111
+ expect(dates[0]).toHaveValue("2020-05-12");
112
+ expect(dates[1]).toHaveValue("2020-06-12");
113
+ });
114
+
115
+ it("deletes an announcement", async () => {
116
+ const user = userEvent.setup();
117
+ jest.spyOn(window, "confirm").mockReturnValue(true);
118
+ const { container } = renderSection();
119
+ expect(announcementEls(container)).toHaveLength(2);
120
+
121
+ await user.click(screen.getAllByRole("button", { name: "Delete" })[0]);
122
+
123
+ const items = announcementEls(container);
124
+ expect(items).toHaveLength(1);
125
+ expect(
126
+ within(items[0]).getByText("Second Announcement")
127
+ ).toBeInTheDocument();
128
+ });
129
+
130
+ it("removes any temporary IDs before submitting the list of announcements", async () => {
131
+ const user = userEvent.setup();
132
+ const ref = React.createRef<AnnouncementsSection>();
133
+ renderSection(ref);
134
+
135
+ // Add a third announcement through the form; it is assigned a temporary id.
136
+ await user.type(screen.getByRole("textbox"), "Third Announcement");
137
+ await user.click(screen.getByRole("button", { name: "Add" }));
138
+
139
+ // `getValue` is the imperative API the parent form calls on submit: it keeps
140
+ // the permanent ids and strips the temporary one.
141
+ const list = ref.current.getValue();
142
+ expect(list[0].id).toBe("perm_1");
143
+ expect(list[1].id).toBe("perm_2");
144
+ expect(list[2].id).toBeUndefined();
145
+ });
146
+ });
@@ -0,0 +1,67 @@
1
+ import * as React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import Autocomplete from "../../../src/components/Autocomplete";
6
+
7
+ describe("Autocomplete", () => {
8
+ const autocompleteValues = ["a", "b", "c"];
9
+
10
+ const renderAutocomplete = (ref?: React.Ref<Autocomplete>) =>
11
+ render(
12
+ <Autocomplete
13
+ ref={ref}
14
+ autocompleteValues={autocompleteValues}
15
+ disabled={false}
16
+ name="test"
17
+ label="Test"
18
+ value="b"
19
+ />
20
+ );
21
+
22
+ describe("rendering", () => {
23
+ it("shows input", () => {
24
+ renderAutocomplete();
25
+
26
+ expect(screen.getByText("Test")).toBeInTheDocument();
27
+ // An <input> with a `list` attribute has the implicit role "combobox".
28
+ const input = screen.getByRole("combobox");
29
+ expect(input).toHaveAttribute("type", "text");
30
+ expect(input).not.toBeDisabled();
31
+ expect(input).toHaveAttribute("name", "test");
32
+ expect(input).toHaveValue("b");
33
+ expect(input).toHaveAttribute("list", "test-autocomplete-list");
34
+ });
35
+
36
+ it("shows autocomplete list", () => {
37
+ const { container } = renderAutocomplete();
38
+
39
+ const list = container.querySelector("datalist#test-autocomplete-list");
40
+ expect(list).toBeInTheDocument();
41
+ const options = list.querySelectorAll("option");
42
+ expect(options).toHaveLength(3);
43
+ expect(options[0]).toHaveAttribute("value", "a");
44
+ expect(options[1]).toHaveAttribute("value", "b");
45
+ expect(options[2]).toHaveAttribute("value", "c");
46
+ });
47
+ });
48
+
49
+ describe("behavior", () => {
50
+ it("returns value", async () => {
51
+ const user = userEvent.setup();
52
+ // getValue() is the imperative API ProtocolFormField/LanguageField call
53
+ // via a ref in production, so exercise it through a real ref here.
54
+ const ref = React.createRef<Autocomplete>();
55
+ renderAutocomplete(ref);
56
+
57
+ expect(ref.current.getValue()).toBe("b");
58
+
59
+ const input = screen.getByRole("combobox");
60
+ await user.clear(input);
61
+ await user.type(input, "test value");
62
+
63
+ expect(input).toHaveValue("test value");
64
+ expect(ref.current.getValue()).toBe("test value");
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,393 @@
1
+ import * as React from "react";
2
+ import { installFormDataShim } from "../testUtils/formDataShim";
3
+ import { act, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+
6
+ import { renderWithProviders } from "../testUtils/withProviders";
7
+ import buildStore from "../../../src/store";
8
+ // Render the CONNECTED default export so that mapStateToProps / mapDispatchToProps
9
+ // are exercised: `preview`, `rightsStatuses`, `isFetching`, the fetch errors, and
10
+ // `bookAdminUrl` are all fed in through the Redux store, while `book`, `bookUrl`,
11
+ // `csrfToken`, and `refreshCatalog` are own props.
12
+ import BookCoverEditor from "../../../src/components/BookCoverEditor";
13
+ import { BookData, RightsStatusData } from "../../../src/interfaces";
14
+ // The reusable-components `Form` builds `new FormData(formElement)` on
15
+ // submit, which the unit jsdom env's undici FormData rejects; install the
16
+ // shared shim that reads the form's successful controls.
17
+ installFormDataShim();
18
+ const rightsStatuses: RightsStatusData = {
19
+ "http://creativecommons.org/licenses/by/4.0/": {
20
+ allows_derivatives: true,
21
+ name: "Creative Commons Attribution (CC BY)",
22
+ open_access: true,
23
+ },
24
+ "http://librarysimplified.org/terms/rights-status/in-copyright": {
25
+ allows_derivatives: false,
26
+ name: "In Copyright",
27
+ open_access: false,
28
+ },
29
+ "https://creativecommons.org/licenses/by-nd/4.0": {
30
+ allows_derivatives: false,
31
+ name: "Creative Commons Attribution-NoDerivs (CC BY-ND)",
32
+ open_access: true,
33
+ },
34
+ };
35
+
36
+ const bookData: BookData = {
37
+ id: "id",
38
+ title: "title",
39
+ coverUrl: "/cover",
40
+ changeCoverLink: {
41
+ href: "/change_cover",
42
+ rel: "http://librarysimplified.org/terms/rel/change_cover",
43
+ },
44
+ };
45
+
46
+ describe("BookCoverEditor", () => {
47
+ let fetchSpy: jest.SpyInstance;
48
+
49
+ // The connected component fetches the rights statuses on mount and posts the
50
+ // cover preview / edited cover. Serve those endpoints and answer anything else
51
+ // (e.g. the OPDS book re-fetch) benignly so nothing rejects after teardown.
52
+ beforeEach(() => {
53
+ fetchSpy = jest.spyOn(globalThis, "fetch").mockImplementation((async (
54
+ url: string
55
+ ) => {
56
+ const u = String(url);
57
+ if (u.includes("rights_status")) {
58
+ return new Response(JSON.stringify(rightsStatuses), {
59
+ status: 200,
60
+ headers: { "Content-Type": "application/json" },
61
+ });
62
+ }
63
+ if (u.includes("preview_book_cover")) {
64
+ return new Response("image data", { status: 200 });
65
+ }
66
+ return new Response("", { status: 200 });
67
+ }) as any);
68
+ });
69
+
70
+ afterEach(() => {
71
+ jest.restoreAllMocks();
72
+ });
73
+
74
+ // Each test gets a fresh store so state does not leak. `preSeed` runs before
75
+ // mount; note the component clears the preview on mount, so preview-slice state
76
+ // must be dispatched after render instead.
77
+ const renderEditor = (
78
+ preSeed?: (store: ReturnType<typeof buildStore>) => void
79
+ ) => {
80
+ const store = buildStore();
81
+ preSeed?.(store);
82
+ const refreshCatalog = jest.fn().mockResolvedValue(undefined);
83
+ const utils = renderWithProviders(
84
+ <BookCoverEditor
85
+ csrfToken="token"
86
+ bookUrl="/book"
87
+ book={bookData}
88
+ refreshCatalog={refreshCatalog}
89
+ />,
90
+ { reduxProviderProps: { store } }
91
+ );
92
+ return { store, refreshCatalog, ...utils };
93
+ };
94
+
95
+ // The rights inputs only appear once the on-mount fetch resolves; awaiting them
96
+ // both settles that fetch and confirms the rights statuses were fetched.
97
+ const awaitMounted = () =>
98
+ screen.findByText("License") as Promise<HTMLElement>;
99
+
100
+ describe("rendering", () => {
101
+ it("shows the book title", async () => {
102
+ renderEditor();
103
+ await awaitMounted();
104
+ expect(
105
+ screen.getByRole("heading", { level: 2, name: "title" })
106
+ ).toBeInTheDocument();
107
+ });
108
+
109
+ it("shows the current cover", async () => {
110
+ const { container } = renderEditor();
111
+ await awaitMounted();
112
+ const cover = container.querySelector(".current-cover");
113
+ expect(cover).toBeInTheDocument();
114
+ expect(cover).toHaveAttribute("src", bookData.coverUrl);
115
+ expect(cover).toHaveAttribute("alt", "Current book cover");
116
+ });
117
+
118
+ it("shows the cover URL and cover file inputs", async () => {
119
+ const { container } = renderEditor();
120
+ await awaitMounted();
121
+
122
+ expect(screen.getByText("URL for cover image")).toBeInTheDocument();
123
+ expect(
124
+ container.querySelector('input[name="cover_url"]')
125
+ ).toBeInTheDocument();
126
+
127
+ expect(screen.getByText("Or upload cover image")).toBeInTheDocument();
128
+ const fileInput = container.querySelector('input[name="cover_file"]');
129
+ expect(fileInput).toHaveAttribute("type", "file");
130
+ expect(fileInput).toHaveAttribute("accept", "image/*");
131
+ });
132
+
133
+ it("shows the title position select", async () => {
134
+ const { container } = renderEditor();
135
+ await awaitMounted();
136
+
137
+ expect(screen.getByText("Title and Author Position")).toBeInTheDocument();
138
+ const select = container.querySelector('select[name="title_position"]');
139
+ expect(select).toBeInTheDocument();
140
+ const options = select.querySelectorAll("option");
141
+ expect(Array.from(options).map((o) => o.getAttribute("value"))).toEqual([
142
+ "none",
143
+ "top",
144
+ "center",
145
+ "bottom",
146
+ ]);
147
+ });
148
+
149
+ it("shows the rights inputs", async () => {
150
+ const { container } = renderEditor();
151
+ await awaitMounted();
152
+
153
+ const rightsSelect = container.querySelector(
154
+ 'select[name="rights_status"]'
155
+ );
156
+ expect(rightsSelect).toBeInTheDocument();
157
+ const options = Array.from(rightsSelect.querySelectorAll("option"));
158
+ expect(options).toHaveLength(3);
159
+ expect(options[0].getAttribute("value")).toBe(
160
+ "http://creativecommons.org/licenses/by/4.0/"
161
+ );
162
+ expect(options[0].textContent).toBe(
163
+ "Creative Commons Attribution (CC BY)"
164
+ );
165
+ expect(options[1].getAttribute("value")).toBe(
166
+ "http://librarysimplified.org/terms/rights-status/in-copyright"
167
+ );
168
+ expect(options[1].textContent).toBe("In Copyright");
169
+ expect(options[2].getAttribute("value")).toBe(
170
+ "http://librarysimplified.org/terms/rights-status/unknown"
171
+ );
172
+ expect(options[2].textContent).toBe("Other");
173
+
174
+ expect(screen.getByText("Explanation of rights")).toBeInTheDocument();
175
+ expect(
176
+ container.querySelector('textarea[name="rights_explanation"]')
177
+ ).toBeInTheDocument();
178
+ });
179
+
180
+ it("shows the updating loader while fetching", async () => {
181
+ const { container } = renderEditor((store) =>
182
+ store.dispatch({ type: "BOOK_COVER_REQUEST" })
183
+ );
184
+ await awaitMounted();
185
+
186
+ // Only the top loader (driven by `isFetching`) shows; the preview loader
187
+ // (driven by `isFetchingPreview`) does not.
188
+ const loaders = container.querySelectorAll(".updating-loader");
189
+ expect(loaders).toHaveLength(1);
190
+ expect(loaders[0]).toHaveTextContent("Updating");
191
+ expect(loaders[0]).not.toHaveTextContent("Updating Preview");
192
+ });
193
+
194
+ it("shows the preview updating message", async () => {
195
+ const { store, container } = renderEditor();
196
+ await awaitMounted();
197
+ expect(container.querySelectorAll(".updating-loader")).toHaveLength(0);
198
+
199
+ act(() => {
200
+ store.dispatch({ type: "PREVIEW_BOOK_COVER_REQUEST" });
201
+ });
202
+
203
+ const loaders = container.querySelectorAll(".updating-loader");
204
+ expect(loaders).toHaveLength(1);
205
+ expect(loaders[0]).toHaveTextContent("Updating Preview");
206
+ });
207
+
208
+ it("shows the preview once it is loaded", async () => {
209
+ const { store, container } = renderEditor();
210
+ await awaitMounted();
211
+ expect(container.querySelector(".preview-cover")).toBeNull();
212
+
213
+ act(() => {
214
+ store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "image data" });
215
+ });
216
+
217
+ const preview = container.querySelector(".preview-cover");
218
+ expect(preview).toBeInTheDocument();
219
+ expect(preview).toHaveAttribute("src", "image data");
220
+ expect(preview).toHaveAttribute("alt", "Preview of new cover");
221
+ });
222
+
223
+ it("shows a fetch error", async () => {
224
+ const { store } = renderEditor();
225
+ await awaitMounted();
226
+ expect(screen.queryByText(/Error:/)).not.toBeInTheDocument();
227
+
228
+ act(() => {
229
+ store.dispatch({
230
+ type: "BOOK_COVER_FAILURE",
231
+ error: { status: 500, url: "url", response: "fetch failed" },
232
+ });
233
+ });
234
+
235
+ expect(screen.getByText("Error: fetch failed")).toBeInTheDocument();
236
+ });
237
+
238
+ it("shows a preview fetch error", async () => {
239
+ const { store } = renderEditor();
240
+ await awaitMounted();
241
+ expect(screen.queryByText(/Error:/)).not.toBeInTheDocument();
242
+
243
+ act(() => {
244
+ store.dispatch({
245
+ type: "PREVIEW_BOOK_COVER_FAILURE",
246
+ error: { status: 500, url: "url", response: "preview failed" },
247
+ });
248
+ });
249
+
250
+ expect(screen.getByText("Error: preview failed")).toBeInTheDocument();
251
+ });
252
+
253
+ it("enables the save button only once there is a preview", async () => {
254
+ const { store } = renderEditor();
255
+ await awaitMounted();
256
+
257
+ const save = screen.getByRole("button", { name: "Save this cover" });
258
+ expect(save).toBeDisabled();
259
+
260
+ act(() => {
261
+ store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "image data" });
262
+ });
263
+
264
+ expect(
265
+ screen.getByRole("button", { name: "Save this cover" })
266
+ ).toBeEnabled();
267
+ });
268
+ });
269
+
270
+ describe("mount behavior", () => {
271
+ it("fetches the rights statuses on mount", async () => {
272
+ renderEditor();
273
+ // The rights inputs only render after the statuses are fetched.
274
+ expect(await screen.findByText("License")).toBeInTheDocument();
275
+ expect(fetchSpy).toHaveBeenCalledWith(
276
+ "/admin/rights_status",
277
+ expect.anything()
278
+ );
279
+ });
280
+
281
+ it("clears any stale preview on mount", async () => {
282
+ const { store, container } = renderEditor((store) =>
283
+ store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "stale" })
284
+ );
285
+ await awaitMounted();
286
+
287
+ // The stale preview was cleared during mount, so it never renders.
288
+ expect(container.querySelector(".preview-cover")).toBeNull();
289
+ expect(store.getState().editor.bookCoverPreview.data).toBeNull();
290
+ });
291
+ });
292
+
293
+ describe("behavior", () => {
294
+ it("previews the cover when the inputs change and the button is clicked", async () => {
295
+ const user = userEvent.setup();
296
+ const { container } = renderEditor();
297
+ await awaitMounted();
298
+
299
+ await user.type(
300
+ container.querySelector('input[name="cover_url"]'),
301
+ "http://example.com"
302
+ );
303
+ await user.click(screen.getByRole("button", { name: "Preview" }));
304
+
305
+ // The preview arrives from the store and is displayed.
306
+ const preview = await screen.findByAltText("Preview of new cover");
307
+ expect(preview).toHaveAttribute("src", "image data");
308
+
309
+ // The preview was posted with the entered cover URL and title position.
310
+ const previewCall = fetchSpy.mock.calls.find(([u]) =>
311
+ String(u).includes("preview_book_cover")
312
+ );
313
+ expect(previewCall).toBeTruthy();
314
+ expect(previewCall[1].method).toBe("POST");
315
+ expect(previewCall[1].body.get("cover_url")).toBe("http://example.com");
316
+ expect(previewCall[1].body.get("title_position")).toBe("none");
317
+ });
318
+
319
+ it("clears the preview when the inputs are emptied", async () => {
320
+ const user = userEvent.setup();
321
+ const { store } = renderEditor();
322
+ await awaitMounted();
323
+ // Seed a preview so we can observe it being cleared.
324
+ act(() => {
325
+ store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "image data" });
326
+ });
327
+ expect(screen.getByAltText("Preview of new cover")).toBeInTheDocument();
328
+
329
+ // With no cover URL or file, submitting the preview form clears it.
330
+ await user.click(screen.getByRole("button", { name: "Preview" }));
331
+
332
+ await waitFor(() =>
333
+ expect(
334
+ screen.queryByAltText("Preview of new cover")
335
+ ).not.toBeInTheDocument()
336
+ );
337
+ // No preview request was posted, since there was nothing to preview.
338
+ expect(
339
+ fetchSpy.mock.calls.some(([u]) =>
340
+ String(u).includes("preview_book_cover")
341
+ )
342
+ ).toBe(false);
343
+ });
344
+
345
+ it("saves the cover with the entered values", async () => {
346
+ const user = userEvent.setup();
347
+ const { store, refreshCatalog } = renderEditor();
348
+ await awaitMounted();
349
+
350
+ // A preview must exist for the save button to be enabled.
351
+ act(() => {
352
+ store.dispatch({ type: "PREVIEW_BOOK_COVER_LOAD", data: "image data" });
353
+ });
354
+
355
+ await user.type(
356
+ document.querySelector('input[name="cover_url"]') as HTMLElement,
357
+ "http://example.com"
358
+ );
359
+ await user.selectOptions(
360
+ document.querySelector('select[name="title_position"]') as HTMLElement,
361
+ "center"
362
+ );
363
+ await user.selectOptions(
364
+ document.querySelector('select[name="rights_status"]') as HTMLElement,
365
+ "http://creativecommons.org/licenses/by/4.0/"
366
+ );
367
+ await user.type(
368
+ document.querySelector(
369
+ 'textarea[name="rights_explanation"]'
370
+ ) as HTMLElement,
371
+ "explanation"
372
+ );
373
+
374
+ await user.click(screen.getByRole("button", { name: "Save this cover" }));
375
+
376
+ // The edited cover is posted to the exact change-cover link with the form
377
+ // values, and on success the catalog is refreshed.
378
+ const editCall = fetchSpy.mock.calls.find(
379
+ ([u]) => String(u) === bookData.changeCoverLink.href
380
+ );
381
+ expect(editCall).toBeTruthy();
382
+ expect(editCall[1].method).toBe("POST");
383
+ expect(editCall[1].body.get("cover_url")).toBe("http://example.com");
384
+ expect(editCall[1].body.get("title_position")).toBe("center");
385
+ expect(editCall[1].body.get("rights_status")).toBe(
386
+ "http://creativecommons.org/licenses/by/4.0/"
387
+ );
388
+ expect(editCall[1].body.get("rights_explanation")).toBe("explanation");
389
+
390
+ await waitFor(() => expect(refreshCatalog).toHaveBeenCalledTimes(1));
391
+ });
392
+ });
393
+ });