@thepalaceproject/circulation-admin 1.43.0-post.9 → 1.44.0
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.
- package/CLAUDE.md +22 -10
- package/README.md +11 -5
- package/dist/circulation-admin.js +1 -1
- package/jest.config.js +11 -44
- package/package.json +1 -5
- package/tests/jest/components/AdvancedSearchBuilder.test.tsx +111 -0
- package/tests/jest/components/Announcement.test.tsx +76 -0
- package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
- package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
- package/tests/jest/components/Autocomplete.test.tsx +67 -0
- package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
- package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
- package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
- package/tests/jest/components/BookEditForm.test.tsx +352 -0
- package/tests/jest/components/BookEditor.test.tsx +20 -21
- package/tests/jest/components/CatalogPage.test.tsx +158 -0
- package/tests/jest/components/CatalogServices.test.tsx +38 -1
- package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
- package/tests/jest/components/Classifications.test.tsx +166 -0
- package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
- package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
- package/tests/jest/components/Collections.test.tsx +335 -7
- package/tests/jest/components/ColorPicker.test.tsx +58 -0
- package/tests/jest/components/ComplaintForm.test.tsx +171 -0
- package/tests/jest/components/Complaints.test.tsx +259 -0
- package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
- package/tests/jest/components/ContextProvider.test.tsx +138 -0
- package/tests/jest/components/Contributors.test.tsx +157 -0
- package/tests/jest/components/CustomListEditor.test.tsx +367 -0
- package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
- package/tests/jest/components/CustomLists.test.tsx +621 -4
- package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
- package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
- package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
- package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
- package/tests/jest/components/EditableConfigList.test.tsx +598 -7
- package/tests/jest/components/EditableInput.test.tsx +250 -1
- package/tests/jest/components/EditorField.test.tsx +95 -0
- package/tests/jest/components/GenreForm.test.tsx +152 -0
- package/tests/jest/components/Header.test.tsx +421 -0
- package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
- package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
- package/tests/jest/components/InputList.test.tsx +493 -0
- package/tests/jest/components/Lane.test.tsx +132 -10
- package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
- package/tests/jest/components/LaneEditor.test.tsx +377 -9
- package/tests/jest/components/Lanes.test.tsx +446 -0
- package/tests/jest/components/LanesSidebar.test.tsx +621 -0
- package/tests/jest/components/Libraries.test.tsx +48 -0
- package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
- package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
- package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
- package/tests/jest/components/MetadataServices.test.tsx +37 -1
- package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
- package/tests/jest/components/PairedMenus.test.tsx +220 -0
- package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
- package/tests/jest/components/ProtocolFormField.test.tsx +443 -7
- package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
- package/tests/jest/components/SelfTests.test.tsx +601 -0
- package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
- package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
- package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
- package/tests/jest/components/Stats.test.tsx +0 -7
- package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
- package/tests/jest/components/ToolTip.test.tsx +46 -0
- package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
- package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
- package/tests/jest/features/book.test.ts +75 -80
- package/tests/jest/fixtures/classificationsData.ts +45 -0
- package/tests/jest/fixtures/genreData.ts +811 -0
- package/tests/jest/README.md +0 -6
- package/tests/jest/legacy-setup.ts +0 -13
- package/tests/jest/legacyJsdomEnvironment.js +0 -49
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { installFormDataShim } from "../testUtils/formDataShim";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
|
|
6
|
+
import { renderWithProviders } from "../testUtils/withProviders";
|
|
7
|
+
import ConnectedChangePasswordForm, {
|
|
8
|
+
ChangePasswordForm,
|
|
9
|
+
} from "../../../src/components/ChangePasswordForm";
|
|
10
|
+
// The reusable-components `Form` builds `new FormData(formElement)` on
|
|
11
|
+
// submit, which the unit jsdom env's undici FormData rejects; install the
|
|
12
|
+
// shared shim that reads the form's successful controls.
|
|
13
|
+
installFormDataShim();
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
jest.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Render the unconnected class so tests can pass a `changePassword` spy and
|
|
19
|
+
// drive props directly. A separate test below renders the connected default
|
|
20
|
+
// export to cover the connect wiring (mapStateToProps/mapDispatchToProps).
|
|
21
|
+
const renderUnconnected = (
|
|
22
|
+
props: Partial<React.ComponentProps<typeof ChangePasswordForm>> = {}
|
|
23
|
+
) =>
|
|
24
|
+
render(
|
|
25
|
+
<ChangePasswordForm
|
|
26
|
+
isFetching={false}
|
|
27
|
+
csrfToken="token"
|
|
28
|
+
changePassword={jest.fn().mockResolvedValue(undefined)}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const passwordInput = (container: HTMLElement) =>
|
|
34
|
+
container.querySelector('input[name="password"]') as HTMLInputElement;
|
|
35
|
+
const confirmInput = (container: HTMLElement) =>
|
|
36
|
+
container.querySelector('input[name="confirm_password"]') as HTMLInputElement;
|
|
37
|
+
|
|
38
|
+
describe("ChangePasswordForm", () => {
|
|
39
|
+
it("shows an error message when there is a fetch error", () => {
|
|
40
|
+
const { container, rerender } = renderUnconnected();
|
|
41
|
+
expect(container.querySelector(".alert-danger")).not.toBeInTheDocument();
|
|
42
|
+
|
|
43
|
+
rerender(
|
|
44
|
+
<ChangePasswordForm
|
|
45
|
+
isFetching={false}
|
|
46
|
+
csrfToken="token"
|
|
47
|
+
changePassword={jest.fn()}
|
|
48
|
+
fetchError={{ status: 500, response: "response", url: "" }}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(container.querySelector(".alert-danger")).toBeInTheDocument();
|
|
53
|
+
expect(screen.getByText("Error: response")).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("shows a loading indicator while fetching", () => {
|
|
57
|
+
const { rerender } = renderUnconnected({ isFetching: false });
|
|
58
|
+
expect(screen.queryByText("Loading")).not.toBeInTheDocument();
|
|
59
|
+
|
|
60
|
+
rerender(
|
|
61
|
+
<ChangePasswordForm
|
|
62
|
+
isFetching={true}
|
|
63
|
+
csrfToken="token"
|
|
64
|
+
changePassword={jest.fn()}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(screen.getByText("Loading")).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("renders the two labeled password inputs", () => {
|
|
72
|
+
const { container } = renderUnconnected();
|
|
73
|
+
|
|
74
|
+
expect(passwordInput(container)).toHaveAttribute("type", "password");
|
|
75
|
+
expect(confirmInput(container)).toHaveAttribute("type", "password");
|
|
76
|
+
|
|
77
|
+
const labels = container.querySelectorAll("label.control-label");
|
|
78
|
+
expect(labels[0]).toHaveTextContent("New Password");
|
|
79
|
+
expect(labels[1]).toHaveTextContent("Confirm New Password");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not submit a blank form", async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
const changePassword = jest.fn().mockResolvedValue(undefined);
|
|
85
|
+
renderUnconnected({ changePassword });
|
|
86
|
+
|
|
87
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
88
|
+
|
|
89
|
+
expect(changePassword).not.toHaveBeenCalled();
|
|
90
|
+
expect(screen.getByText("Fields cannot be blank.")).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Rendered fresh, so the error message asserted below can only have come from
|
|
94
|
+
// this partial submit rather than lingering from an earlier blank one.
|
|
95
|
+
it("does not submit a partially blank form", async () => {
|
|
96
|
+
const user = userEvent.setup();
|
|
97
|
+
const changePassword = jest.fn().mockResolvedValue(undefined);
|
|
98
|
+
const { container } = renderUnconnected({ changePassword });
|
|
99
|
+
expect(screen.queryByText("Fields cannot be blank.")).toBeNull();
|
|
100
|
+
|
|
101
|
+
// Only the first field filled.
|
|
102
|
+
await user.type(passwordInput(container), "newPassword");
|
|
103
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
104
|
+
|
|
105
|
+
expect(changePassword).not.toHaveBeenCalled();
|
|
106
|
+
expect(screen.getByText("Fields cannot be blank.")).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not submit when the passwords do not match", async () => {
|
|
110
|
+
const user = userEvent.setup();
|
|
111
|
+
const changePassword = jest.fn().mockResolvedValue(undefined);
|
|
112
|
+
const { container } = renderUnconnected({ changePassword });
|
|
113
|
+
|
|
114
|
+
await user.type(passwordInput(container), "newPassword");
|
|
115
|
+
await user.type(confirmInput(container), "somethingElse");
|
|
116
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
117
|
+
|
|
118
|
+
expect(changePassword).not.toHaveBeenCalled();
|
|
119
|
+
expect(screen.getByText("Passwords do not match.")).toBeInTheDocument();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("submits the new password and shows a success message", async () => {
|
|
123
|
+
const user = userEvent.setup();
|
|
124
|
+
const changePassword = jest.fn().mockResolvedValue(undefined);
|
|
125
|
+
const { container } = renderUnconnected({ changePassword });
|
|
126
|
+
|
|
127
|
+
await user.type(passwordInput(container), "newPassword");
|
|
128
|
+
await user.type(confirmInput(container), "newPassword");
|
|
129
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
130
|
+
|
|
131
|
+
expect(changePassword).toHaveBeenCalledTimes(1);
|
|
132
|
+
const submittedData = changePassword.mock.calls[0][0];
|
|
133
|
+
expect(submittedData.get("password")).toBe("newPassword");
|
|
134
|
+
expect(submittedData.get("confirm_password")).toBe("newPassword");
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
await screen.findByText("Password changed successfully")
|
|
138
|
+
).toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("dispatches the password change through the connected store", async () => {
|
|
142
|
+
const user = userEvent.setup();
|
|
143
|
+
const fetchSpy = jest
|
|
144
|
+
.spyOn(globalThis, "fetch")
|
|
145
|
+
.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
146
|
+
|
|
147
|
+
const { container } = renderWithProviders(
|
|
148
|
+
<ConnectedChangePasswordForm csrfToken="token" />
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await user.type(passwordInput(container), "newPassword");
|
|
152
|
+
await user.type(confirmInput(container), "newPassword");
|
|
153
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
154
|
+
|
|
155
|
+
expect(
|
|
156
|
+
await screen.findByText("Password changed successfully")
|
|
157
|
+
).toBeInTheDocument();
|
|
158
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
159
|
+
"/admin/change_password",
|
|
160
|
+
expect.objectContaining({ method: "POST" })
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
|
|
5
|
+
import { renderWithProviders } from "../testUtils/withProviders";
|
|
6
|
+
import buildStore from "../../../src/store";
|
|
7
|
+
import genreData from "../fixtures/genreData";
|
|
8
|
+
import classificationsData from "../fixtures/classificationsData";
|
|
9
|
+
// Render the CONNECTED default export so that mapStateToProps / mapDispatchToProps
|
|
10
|
+
// are exercised. The genre tree and classifications are fetched on mount and fed
|
|
11
|
+
// back in through the Redux store; `book` and `refreshCatalog` are own props.
|
|
12
|
+
import Classifications, {
|
|
13
|
+
Classifications as UnconnectedClassifications,
|
|
14
|
+
} from "../../../src/components/Classifications";
|
|
15
|
+
|
|
16
|
+
// A `targetAgeRange` is required: ClassificationsForm reads it in bookChanged on
|
|
17
|
+
// re-render (the legacy enzyme test used shallow, so the child never re-rendered).
|
|
18
|
+
const bookData = {
|
|
19
|
+
title: "title",
|
|
20
|
+
fiction: true,
|
|
21
|
+
categories: ["Space Opera"],
|
|
22
|
+
targetAgeRange: [null, null],
|
|
23
|
+
};
|
|
24
|
+
const bookUrl = "works/1234";
|
|
25
|
+
const classificationsUrl = "admin/works/1234/classifications";
|
|
26
|
+
|
|
27
|
+
const jsonResponse = (body: unknown) =>
|
|
28
|
+
new Response(JSON.stringify(body), {
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Resolves each fetch with the appropriate body based on the request URL/method.
|
|
34
|
+
const stubFetch = () =>
|
|
35
|
+
jest
|
|
36
|
+
.spyOn(globalThis, "fetch")
|
|
37
|
+
.mockImplementation(async (url, opts?: RequestInit) => {
|
|
38
|
+
const u = String(url);
|
|
39
|
+
if (opts?.method === "POST") {
|
|
40
|
+
return new Response("success", { status: 200 });
|
|
41
|
+
}
|
|
42
|
+
if (u === "/admin/genres") {
|
|
43
|
+
return jsonResponse(genreData);
|
|
44
|
+
}
|
|
45
|
+
if (u.endsWith("/classifications")) {
|
|
46
|
+
return jsonResponse({ classifications: classificationsData });
|
|
47
|
+
}
|
|
48
|
+
// getBookData (OPDS) on refresh — non-OPDS body so it settles quietly.
|
|
49
|
+
return new Response("{}", { status: 200 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const renderConnected = (
|
|
53
|
+
overrides: Partial<{ refreshCatalog: () => Promise<any> }> = {}
|
|
54
|
+
) => {
|
|
55
|
+
const store = buildStore();
|
|
56
|
+
const refreshCatalog =
|
|
57
|
+
overrides.refreshCatalog ?? jest.fn().mockResolvedValue(undefined);
|
|
58
|
+
const result = renderWithProviders(
|
|
59
|
+
<Classifications
|
|
60
|
+
store={store}
|
|
61
|
+
csrfToken="token"
|
|
62
|
+
bookUrl={bookUrl}
|
|
63
|
+
book={bookData as any}
|
|
64
|
+
refreshCatalog={refreshCatalog}
|
|
65
|
+
/>,
|
|
66
|
+
{ reduxProviderProps: { store } }
|
|
67
|
+
);
|
|
68
|
+
return { ...result, refreshCatalog };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
describe("Classifications", () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
jest.restoreAllMocks();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("shows the book title", async () => {
|
|
77
|
+
stubFetch();
|
|
78
|
+
renderConnected();
|
|
79
|
+
expect(
|
|
80
|
+
await screen.findByRole("heading", { level: 2, name: "title" })
|
|
81
|
+
).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("shows the updating indicator while fetching", () => {
|
|
85
|
+
// A never-resolving fetch keeps the panel in its fetching state.
|
|
86
|
+
jest
|
|
87
|
+
.spyOn(globalThis, "fetch")
|
|
88
|
+
.mockImplementation(() => new Promise(() => {}));
|
|
89
|
+
const { container } = renderConnected();
|
|
90
|
+
expect(container.querySelector(".updating-loader")).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("shows the classifications form and table once the data loads, and hides the updating indicator", async () => {
|
|
94
|
+
stubFetch();
|
|
95
|
+
const { container } = renderConnected();
|
|
96
|
+
|
|
97
|
+
// The form renders once the genre tree loads.
|
|
98
|
+
expect(
|
|
99
|
+
await screen.findByRole("button", { name: "Save" })
|
|
100
|
+
).toBeInTheDocument();
|
|
101
|
+
// The table renders once the classifications load.
|
|
102
|
+
expect(
|
|
103
|
+
screen.getByRole("heading", { name: "Related Classifications" })
|
|
104
|
+
).toBeInTheDocument();
|
|
105
|
+
// A sample classification from the fixture shows in the table.
|
|
106
|
+
expect(screen.getByText("FICTION / Ghost")).toBeInTheDocument();
|
|
107
|
+
// Fetching is done, so the updating indicator is gone.
|
|
108
|
+
expect(container.querySelector(".updating-loader")).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("shows a fetch error", () => {
|
|
112
|
+
// A network failure on mount would reject the (uncaught) fetch promise and
|
|
113
|
+
// crash the worker, so drive the error through the prop that connect derives.
|
|
114
|
+
render(
|
|
115
|
+
<UnconnectedClassifications
|
|
116
|
+
store={buildStore() as any}
|
|
117
|
+
csrfToken="token"
|
|
118
|
+
bookUrl={bookUrl}
|
|
119
|
+
book={bookData as any}
|
|
120
|
+
refreshCatalog={jest.fn().mockResolvedValue(undefined)}
|
|
121
|
+
bookAdminUrl="book admin url"
|
|
122
|
+
fetchError={{ status: 500, url: "url", response: "response" } as any}
|
|
123
|
+
fetchGenreTree={jest.fn()}
|
|
124
|
+
fetchClassifications={jest.fn()}
|
|
125
|
+
fetchBook={jest.fn()}
|
|
126
|
+
editClassifications={jest.fn().mockResolvedValue(undefined)}
|
|
127
|
+
genreTree={null}
|
|
128
|
+
classifications={null}
|
|
129
|
+
isFetching={false}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
expect(screen.getByText(/Error: response/)).toBeInTheDocument();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("fetches the genre tree and classifications on mount", async () => {
|
|
136
|
+
const fetchSpy = stubFetch();
|
|
137
|
+
renderConnected();
|
|
138
|
+
await screen.findByRole("button", { name: "Save" });
|
|
139
|
+
|
|
140
|
+
const urls = fetchSpy.mock.calls.map(([u]) => String(u));
|
|
141
|
+
expect(urls).toContain("/admin/genres");
|
|
142
|
+
expect(urls).toContain(classificationsUrl);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("refreshes the book, classifications, and catalog after editing classifications", async () => {
|
|
146
|
+
const user = userEvent.setup();
|
|
147
|
+
const fetchSpy = stubFetch();
|
|
148
|
+
const { container, refreshCatalog } = renderConnected();
|
|
149
|
+
|
|
150
|
+
await screen.findByRole("button", { name: "Save" });
|
|
151
|
+
|
|
152
|
+
// The form requires an audience selection before it will submit.
|
|
153
|
+
await user.selectOptions(
|
|
154
|
+
container.querySelector('select[name="audience"]') as HTMLSelectElement,
|
|
155
|
+
"Adult"
|
|
156
|
+
);
|
|
157
|
+
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
158
|
+
|
|
159
|
+
// Editing POSTs the edit_classifications URL, then refreshes the catalog.
|
|
160
|
+
await waitFor(() => expect(refreshCatalog).toHaveBeenCalledTimes(1));
|
|
161
|
+
const postCall = fetchSpy.mock.calls.find(
|
|
162
|
+
([, opts]) => (opts as RequestInit)?.method === "POST"
|
|
163
|
+
);
|
|
164
|
+
expect(String(postCall[0])).toBe("admin/works/1234/edit_classifications");
|
|
165
|
+
});
|
|
166
|
+
});
|