@thepalaceproject/circulation-admin 1.43.0-post.15 → 1.43.0-post.17

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/package.json CHANGED
@@ -143,5 +143,5 @@
143
143
  "*.{js,jsx,ts,tsx,css,md}": "prettier --write",
144
144
  "*.{js,css,md}": "prettier --write"
145
145
  },
146
- "version": "1.43.0-post.15"
146
+ "version": "1.43.0-post.17"
147
147
  }
@@ -0,0 +1,229 @@
1
+ import * as React from "react";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ import AnnouncementForm from "../../../src/components/AnnouncementForm";
6
+
7
+ // 66 characters — within the 15-350 valid range.
8
+ const CONTENT =
9
+ "Here is some sample content which comes out to over 15 characters.";
10
+
11
+ const getTextarea = () => screen.getByRole("textbox") as HTMLTextAreaElement;
12
+ const getDateInputs = (container: HTMLElement) =>
13
+ Array.from(
14
+ container.querySelectorAll<HTMLInputElement>('input[type="date"]')
15
+ );
16
+ const getCounter = () => screen.getByText(/Current length:/);
17
+
18
+ // Enter valid content and a start/finish date.
19
+ const fillOutForm = async (
20
+ user: ReturnType<typeof userEvent.setup>,
21
+ container: HTMLElement
22
+ ) => {
23
+ await user.type(getTextarea(), CONTENT);
24
+ const [start, finish] = getDateInputs(container);
25
+ // Date inputs are read via a ref, so `fireEvent.change` (set value + dispatch)
26
+ // reliably sets the value.
27
+ fireEvent.change(start, { target: { value: "2020-06-01" } });
28
+ fireEvent.change(finish, { target: { value: "2020-07-01" } });
29
+ };
30
+
31
+ describe("AnnouncementForm", () => {
32
+ let add: jest.Mock;
33
+ beforeEach(() => {
34
+ add = jest.fn();
35
+ });
36
+
37
+ it("renders the input fields", () => {
38
+ const { container } = render(<AnnouncementForm add={add} />);
39
+
40
+ // Content field: a textarea carrying the 15/350 length constraints.
41
+ const textarea = getTextarea();
42
+ expect(textarea.tagName).toBe("TEXTAREA");
43
+ expect(textarea).toHaveAttribute("minlength", "15");
44
+ expect(textarea).toHaveAttribute("maxlength", "350");
45
+ expect(
46
+ screen.getByText("New Announcement Text (15-350 characters)")
47
+ ).toBeInTheDocument();
48
+ expect(screen.getByText("(Current length: 0/350)")).toBeInTheDocument();
49
+
50
+ // Two date fields, each with its label and description.
51
+ const dates = getDateInputs(container);
52
+ expect(dates).toHaveLength(2);
53
+ // EditableInput prepends "(Optional) " to each description, so match the
54
+ // full sentence as a substring rather than the whole text content.
55
+ expect(screen.getByText("Start Date")).toBeInTheDocument();
56
+ expect(
57
+ screen.getByText(
58
+ /If no start date is chosen, the default start date is today's date\./
59
+ )
60
+ ).toBeInTheDocument();
61
+ expect(screen.getByText("End Date")).toBeInTheDocument();
62
+ expect(
63
+ screen.getByText(
64
+ /If no expiration date is chosen, the default expiration date is 2 months from the start date\./
65
+ )
66
+ ).toBeInTheDocument();
67
+ });
68
+
69
+ // The other tests only compare the date fields against whatever they rendered
70
+ // with, so they would all still pass if the defaults came out blank. Pin the
71
+ // clock and assert the actual values.
72
+ it("prefills the date fields with today and two months from today", () => {
73
+ jest.useFakeTimers().setSystemTime(new Date(2026, 2, 15));
74
+ try {
75
+ const { container } = render(<AnnouncementForm add={add} />);
76
+
77
+ const [start, finish] = getDateInputs(container);
78
+ expect(start.value).toBe("2026-03-15");
79
+ expect(finish.value).toBe("2026-05-15");
80
+ } finally {
81
+ jest.useRealTimers();
82
+ }
83
+ });
84
+
85
+ it("renders the buttons", () => {
86
+ render(<AnnouncementForm add={add} />);
87
+ expect(screen.getAllByRole("button")).toHaveLength(2);
88
+ expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
89
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
90
+ });
91
+
92
+ it("keeps track of whether the content is too short or too long", async () => {
93
+ const user = userEvent.setup();
94
+ const { container } = render(<AnnouncementForm add={add} />);
95
+
96
+ // Empty content: 0/350, flagged as the wrong length, Add disabled.
97
+ expect(getCounter()).toHaveTextContent("(Current length: 0/350)");
98
+ expect(getCounter().parentElement).toHaveClass("wrong-length");
99
+ expect(screen.getByRole("button", { name: "Add" })).toBeDisabled();
100
+
101
+ // Valid content: length flag cleared and Add enabled.
102
+ await fillOutForm(user, container);
103
+ expect(getCounter()).toHaveTextContent("(Current length: 66/350)");
104
+ expect(getCounter().parentElement).not.toHaveClass("wrong-length");
105
+ expect(screen.getByRole("button", { name: "Add" })).not.toBeDisabled();
106
+
107
+ // Content at the 350-character cap is flagged as too long again.
108
+ const longString =
109
+ "Here's some extremely/gratuitously long content. The point of the content is just to get up to 350 characters so that I can test whether the class name will change. I realize even as I am typing this that it probably would have been a better idea to do it with filler text; oh well...? Anyway, I have now written the most boring announcement EVER.";
110
+ fireEvent.change(getTextarea(), { target: { value: longString } });
111
+ expect(getCounter()).toHaveTextContent("(Current length: 350/350)");
112
+ expect(getCounter().parentElement).toHaveClass("wrong-length");
113
+ });
114
+
115
+ it("adds a new announcement", async () => {
116
+ const user = userEvent.setup();
117
+ const { container } = render(<AnnouncementForm add={add} />);
118
+ await fillOutForm(user, container);
119
+
120
+ await user.click(screen.getByRole("button", { name: "Add" }));
121
+
122
+ expect(add).toHaveBeenCalledTimes(1);
123
+ expect(add.mock.calls[0][0].content).toBe(CONTENT);
124
+ expect(add.mock.calls[0][0].start).toBe("2020-06-01");
125
+ expect(add.mock.calls[0][0].finish).toBe("2020-07-01");
126
+ });
127
+
128
+ it("cancels adding a new announcement", async () => {
129
+ const user = userEvent.setup();
130
+ const { container } = render(<AnnouncementForm add={add} />);
131
+ const [start0, finish0] = getDateInputs(container);
132
+ const defaultStart = start0.value;
133
+ const defaultFinish = finish0.value;
134
+
135
+ await fillOutForm(user, container);
136
+ expect(getTextarea()).toHaveValue(CONTENT);
137
+
138
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
139
+
140
+ // Cancelling a brand-new announcement adds nothing and restores the
141
+ // empty content field and the default dates.
142
+ expect(add).not.toHaveBeenCalled();
143
+ expect(getTextarea()).toHaveValue("");
144
+ expect(getCounter()).toHaveTextContent("(Current length: 0/350)");
145
+ const [start1, finish1] = getDateInputs(container);
146
+ expect(start1.value).toBe(defaultStart);
147
+ expect(finish1.value).toBe(defaultFinish);
148
+ });
149
+
150
+ it("edits an existing announcement", async () => {
151
+ const user = userEvent.setup();
152
+ const { container, rerender } = render(<AnnouncementForm add={add} />);
153
+ const [start0, finish0] = getDateInputs(container);
154
+ const defaultStart = start0.value;
155
+ const defaultFinish = finish0.value;
156
+
157
+ // Supplying an existing announcement's props switches the form to edit mode.
158
+ rerender(
159
+ <AnnouncementForm
160
+ add={add}
161
+ content={CONTENT}
162
+ start="07/01/2020"
163
+ finish="08/01/2020"
164
+ />
165
+ );
166
+
167
+ // The form is populated with the existing announcement (dates reformatted).
168
+ expect(getTextarea()).toHaveValue(CONTENT);
169
+ const [start1, finish1] = getDateInputs(container);
170
+ expect(start1.value).toBe("2020-07-01");
171
+ expect(finish1.value).toBe("2020-08-01");
172
+
173
+ // Edit the content and save it.
174
+ fireEvent.change(getTextarea(), {
175
+ target: { value: "Here is an edited version of the content" },
176
+ });
177
+ await user.click(screen.getByRole("button", { name: "Add" }));
178
+
179
+ expect(add).toHaveBeenCalledTimes(1);
180
+ expect(add.mock.calls[0][0].content).toBe(
181
+ "Here is an edited version of the content"
182
+ );
183
+
184
+ // The form resets to an empty content field and the default dates.
185
+ expect(getTextarea()).toHaveValue("");
186
+ const [start2, finish2] = getDateInputs(container);
187
+ expect(start2.value).toBe(defaultStart);
188
+ expect(finish2.value).toBe(defaultFinish);
189
+ });
190
+
191
+ it("cancels editing an existing announcement", async () => {
192
+ const user = userEvent.setup();
193
+ const { container, rerender } = render(<AnnouncementForm add={add} />);
194
+ const [start0, finish0] = getDateInputs(container);
195
+ const defaultStart = start0.value;
196
+ const defaultFinish = finish0.value;
197
+
198
+ rerender(
199
+ <AnnouncementForm
200
+ add={add}
201
+ content={CONTENT}
202
+ start="07/01/2020"
203
+ finish="08/01/2020"
204
+ />
205
+ );
206
+
207
+ expect(getTextarea()).toHaveValue(CONTENT);
208
+ const [start1, finish1] = getDateInputs(container);
209
+ expect(start1.value).toBe("2020-07-01");
210
+ expect(finish1.value).toBe("2020-08-01");
211
+
212
+ fireEvent.change(getTextarea(), {
213
+ target: { value: "Here is an edited version of the content" },
214
+ });
215
+ // Cancelling while editing re-submits the (edited) announcement rather than
216
+ // discarding it — unlike cancelling a brand-new one.
217
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
218
+
219
+ expect(add).toHaveBeenCalledTimes(1);
220
+ expect(add.mock.calls[0][0].content).toBe(
221
+ "Here is an edited version of the content"
222
+ );
223
+
224
+ expect(getTextarea()).toHaveValue("");
225
+ const [start2, finish2] = getDateInputs(container);
226
+ expect(start2.value).toBe(defaultStart);
227
+ expect(finish2.value).toBe(defaultFinish);
228
+ });
229
+ });
@@ -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,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
+ });