@thepalaceproject/circulation-admin 1.43.0-post.9 → 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 (73) hide show
  1. package/CLAUDE.md +22 -10
  2. package/README.md +11 -5
  3. package/dist/circulation-admin.js +1 -1
  4. package/jest.config.js +11 -44
  5. package/package.json +1 -5
  6. package/tests/jest/components/AdvancedSearchBuilder.test.tsx +111 -0
  7. package/tests/jest/components/Announcement.test.tsx +76 -0
  8. package/tests/jest/components/AnnouncementForm.test.tsx +229 -0
  9. package/tests/jest/components/AnnouncementsSection.test.tsx +146 -0
  10. package/tests/jest/components/Autocomplete.test.tsx +67 -0
  11. package/tests/jest/components/BookCoverEditor.test.tsx +393 -0
  12. package/tests/jest/components/BookDetailsEditor.test.tsx +275 -0
  13. package/tests/jest/components/BookDetailsTabContainer.test.tsx +237 -0
  14. package/tests/jest/components/BookEditForm.test.tsx +352 -0
  15. package/tests/jest/components/BookEditor.test.tsx +20 -21
  16. package/tests/jest/components/CatalogPage.test.tsx +158 -0
  17. package/tests/jest/components/CatalogServices.test.tsx +38 -1
  18. package/tests/jest/components/ChangePasswordForm.test.tsx +163 -0
  19. package/tests/jest/components/Classifications.test.tsx +166 -0
  20. package/tests/jest/components/ClassificationsForm.test.tsx +458 -0
  21. package/tests/jest/components/ClassificationsTable.test.tsx +57 -0
  22. package/tests/jest/components/Collections.test.tsx +335 -7
  23. package/tests/jest/components/ColorPicker.test.tsx +58 -0
  24. package/tests/jest/components/ComplaintForm.test.tsx +171 -0
  25. package/tests/jest/components/Complaints.test.tsx +259 -0
  26. package/tests/jest/components/ConfigTabContainer.test.tsx +196 -0
  27. package/tests/jest/components/ContextProvider.test.tsx +138 -0
  28. package/tests/jest/components/Contributors.test.tsx +157 -0
  29. package/tests/jest/components/CustomListEditor.test.tsx +367 -0
  30. package/tests/jest/components/CustomListEntriesEditor.test.tsx +1240 -0
  31. package/tests/jest/components/CustomLists.test.tsx +621 -4
  32. package/tests/jest/components/CustomListsForBook.test.tsx +353 -0
  33. package/tests/jest/components/DiagnosticsServiceTabs.test.tsx +141 -0
  34. package/tests/jest/components/DiagnosticsServiceType.test.tsx +75 -0
  35. package/tests/jest/components/DiscoveryServices.test.tsx +154 -7
  36. package/tests/jest/components/EditableConfigList.test.tsx +598 -7
  37. package/tests/jest/components/EditableInput.test.tsx +250 -1
  38. package/tests/jest/components/EditorField.test.tsx +95 -0
  39. package/tests/jest/components/GenreForm.test.tsx +152 -0
  40. package/tests/jest/components/Header.test.tsx +421 -0
  41. package/tests/jest/components/IndividualAdminEditForm.test.tsx +506 -1
  42. package/tests/jest/components/IndividualAdmins.test.tsx +124 -8
  43. package/tests/jest/components/InputList.test.tsx +493 -0
  44. package/tests/jest/components/Lane.test.tsx +132 -10
  45. package/tests/jest/components/LaneCustomListsEditor.test.tsx +450 -0
  46. package/tests/jest/components/LaneEditor.test.tsx +377 -9
  47. package/tests/jest/components/Lanes.test.tsx +446 -0
  48. package/tests/jest/components/LanesSidebar.test.tsx +621 -0
  49. package/tests/jest/components/Libraries.test.tsx +48 -0
  50. package/tests/jest/components/LibraryEditForm.test.tsx +383 -0
  51. package/tests/jest/components/LibraryRegistration.test.tsx +354 -0
  52. package/tests/jest/components/LibraryRegistrationForm.test.tsx +203 -0
  53. package/tests/jest/components/MetadataServices.test.tsx +37 -1
  54. package/tests/jest/components/NeighborhoodAnalyticsForm.test.tsx +167 -0
  55. package/tests/jest/components/PairedMenus.test.tsx +220 -0
  56. package/tests/jest/components/PatronAuthServices.test.tsx +114 -0
  57. package/tests/jest/components/ProtocolFormField.test.tsx +443 -7
  58. package/tests/jest/components/ResetAdobeId.test.tsx +302 -0
  59. package/tests/jest/components/SelfTests.test.tsx +601 -0
  60. package/tests/jest/components/SelfTestsTabContainer.test.tsx +216 -0
  61. package/tests/jest/components/ServiceEditForm.test.tsx +1092 -0
  62. package/tests/jest/components/SitewideAnnouncements.test.tsx +134 -0
  63. package/tests/jest/components/Stats.test.tsx +0 -7
  64. package/tests/jest/components/TextWithEditMode.test.tsx +218 -0
  65. package/tests/jest/components/ToolTip.test.tsx +46 -0
  66. package/tests/jest/components/TroubleshootingCategoryPage.test.tsx +69 -0
  67. package/tests/jest/components/TroubleshootingPage.test.tsx +94 -0
  68. package/tests/jest/features/book.test.ts +75 -80
  69. package/tests/jest/fixtures/classificationsData.ts +45 -0
  70. package/tests/jest/fixtures/genreData.ts +811 -0
  71. package/tests/jest/README.md +0 -6
  72. package/tests/jest/legacy-setup.ts +0 -13
  73. package/tests/jest/legacyJsdomEnvironment.js +0 -49
@@ -0,0 +1,353 @@
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
+
8
+ // CustomListsForBook renders a react-router v3 <Link> for which
9
+ // renderWithProviders supplies no router context. Mock it to a marker that
10
+ // exposes the `to` pathname and state so the destination can be asserted.
11
+ jest.mock("react-router", () => ({
12
+ ...jest.requireActual("react-router"),
13
+ Link: (props: any) => (
14
+ <div
15
+ data-testid="Link"
16
+ data-pathname={
17
+ typeof props.to === "string" ? props.to : props.to?.pathname
18
+ }
19
+ data-book-title={
20
+ typeof props.to === "object" ? props.to?.state?.bookTitle : undefined
21
+ }
22
+ >
23
+ {props.children}
24
+ </div>
25
+ ),
26
+ }));
27
+
28
+ // Render the CONNECTED default export so that mapStateToProps / mapDispatchToProps
29
+ // are exercised. The book's lists and all available lists are fetched on mount
30
+ // and fed back in through the Redux store; book/bookUrl/library/refreshCatalog
31
+ // are own props.
32
+ import CustomListsForBook, {
33
+ CustomListsForBook as UnconnectedCustomListsForBook,
34
+ } from "../../../src/components/CustomListsForBook";
35
+
36
+ const allCustomLists = [
37
+ { id: "1", name: "list 1", is_owner: true, is_shared: false },
38
+ { id: "2", name: "list 2", is_owner: true, is_shared: false },
39
+ { id: "3", name: "list 3", is_owner: true, is_shared: false },
40
+ ];
41
+ const listsForBook = [
42
+ { id: "2", name: "list 2", is_owner: true, is_shared: false },
43
+ ];
44
+
45
+ const jsonResponse = (body: unknown) =>
46
+ new Response(JSON.stringify(body), {
47
+ status: 200,
48
+ headers: { "Content-Type": "application/json" },
49
+ });
50
+
51
+ // Serves the "all custom lists" endpoint and the per-book "/lists" endpoint from
52
+ // the supplied fixtures; answers POSTs (edit) with success.
53
+ const stubFetch = (allLists: unknown[], forBook: unknown[]) =>
54
+ jest
55
+ .spyOn(globalThis, "fetch")
56
+ .mockImplementation(async (url, opts?: RequestInit) => {
57
+ if (opts?.method === "POST") {
58
+ return new Response("success", { status: 200 });
59
+ }
60
+ const u = String(url);
61
+ if (u.includes("custom_lists")) {
62
+ return jsonResponse({ custom_lists: allLists });
63
+ }
64
+ return jsonResponse({ custom_lists: forBook });
65
+ });
66
+
67
+ const renderConnected = () => {
68
+ const store = buildStore();
69
+ const refreshCatalog = jest.fn().mockResolvedValue(undefined);
70
+ const result = renderWithProviders(
71
+ <CustomListsForBook
72
+ csrfToken="token"
73
+ book={{ id: "id", title: "test title" } as any}
74
+ bookUrl="works/book url"
75
+ library="library"
76
+ refreshCatalog={refreshCatalog}
77
+ />,
78
+ { reduxProviderProps: { store } }
79
+ );
80
+ return { ...result, refreshCatalog };
81
+ };
82
+
83
+ describe("CustomListsForBook", () => {
84
+ afterEach(() => {
85
+ jest.restoreAllMocks();
86
+ });
87
+
88
+ it("shows the book title and the book's current lists", async () => {
89
+ stubFetch(allCustomLists, listsForBook);
90
+ const { container } = renderConnected();
91
+
92
+ expect(
93
+ await screen.findByRole("heading", { level: 2, name: "test title" })
94
+ ).toBeInTheDocument();
95
+ expect(
96
+ screen.getByRole("heading", { name: "Current Lists" })
97
+ ).toBeInTheDocument();
98
+
99
+ const link = await screen.findByRole("link", { name: "list 2" });
100
+ expect(link).toHaveAttribute("href", "/admin/web/lists/library/edit/2");
101
+ // Exactly one current list is shown.
102
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(1);
103
+ });
104
+
105
+ it("builds the current-list URL from the list's id, not its name", async () => {
106
+ // A list named "new" whose id is "42" proves the URL uses the id.
107
+ const newList = { id: "42", name: "new", is_owner: true, is_shared: false };
108
+ stubFetch([...allCustomLists, newList], [newList]);
109
+ renderConnected();
110
+
111
+ const link = await screen.findByRole("link", { name: "new" });
112
+ expect(link).toHaveAttribute("href", "/admin/web/lists/library/edit/42");
113
+ });
114
+
115
+ it("shows a menu of the lists the book is not yet on", async () => {
116
+ stubFetch(allCustomLists, listsForBook);
117
+ const { container } = renderConnected();
118
+
119
+ await screen.findByRole("link", { name: "list 2" });
120
+
121
+ const select = container.querySelector("select");
122
+ expect(select).not.toBeNull();
123
+ const optionValues = Array.from(select.querySelectorAll("option")).map(
124
+ (o) => o.getAttribute("value")
125
+ );
126
+ // "list 2" is excluded because the book is already on it.
127
+ expect(optionValues).toStrictEqual(["list 1", "list 3"]);
128
+ expect(screen.getByText(/Select an existing list/)).toBeInTheDocument();
129
+ expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
130
+ });
131
+
132
+ it("shows placeholder text when the book is not on any list", async () => {
133
+ stubFetch(allCustomLists, []);
134
+ const { container } = renderConnected();
135
+
136
+ expect(
137
+ await screen.findByText("This book is not currently on any lists.")
138
+ ).toBeInTheDocument();
139
+ // With no current lists there is no "Current Lists" heading.
140
+ expect(
141
+ screen.queryByRole("heading", { name: "Current Lists" })
142
+ ).not.toBeInTheDocument();
143
+ expect(container.querySelector(".with-remove-button")).toBeNull();
144
+ });
145
+
146
+ it("hides the menu once the book is on every available list", async () => {
147
+ stubFetch(allCustomLists, allCustomLists);
148
+ const { container } = renderConnected();
149
+
150
+ expect(
151
+ await screen.findByText(
152
+ "This book has been added to all the available lists."
153
+ )
154
+ ).toBeInTheDocument();
155
+ expect(container.querySelector("select")).toBeNull();
156
+ });
157
+
158
+ it("shows a message instead of the input list when no lists exist", async () => {
159
+ stubFetch([], []);
160
+ renderConnected();
161
+
162
+ expect(
163
+ await screen.findByText("There are no available lists.")
164
+ ).toBeInTheDocument();
165
+ });
166
+
167
+ it("shows a link to the list creator that carries the book title", async () => {
168
+ stubFetch(allCustomLists, listsForBook);
169
+ renderConnected();
170
+
171
+ await screen.findByRole("link", { name: "list 2" });
172
+
173
+ const creatorLink = screen.getByTestId("Link");
174
+ expect(creatorLink).toHaveTextContent("Create a new list");
175
+ expect(creatorLink).toHaveAttribute(
176
+ "data-pathname",
177
+ "/admin/web/lists/library/create"
178
+ );
179
+ expect(creatorLink).toHaveAttribute("data-book-title", "test title");
180
+ expect(
181
+ screen.getByText(
182
+ "(The book title will be automatically copied and searched on the list creator page.)"
183
+ )
184
+ ).toBeInTheDocument();
185
+ });
186
+
187
+ it("fetches the book's lists and all custom lists on mount, without refreshing the catalog", async () => {
188
+ const fetchSpy = stubFetch(allCustomLists, listsForBook);
189
+ const { refreshCatalog } = renderConnected();
190
+
191
+ await screen.findByRole("link", { name: "list 2" });
192
+
193
+ const urls = fetchSpy.mock.calls.map(([u]) => String(u));
194
+ expect(urls).toContain("admin/works/book url/lists");
195
+ expect(urls.some((u) => u.includes("custom_lists"))).toBe(true);
196
+ expect(refreshCatalog).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("does not re-fetch all custom lists on mount when they are already loaded", () => {
200
+ // The mount guard (CustomListsForBook.tsx:158) fetches the full list of
201
+ // custom lists only when they are not already present; the book's own lists
202
+ // are still fetched. Drive the already-loaded case through the unconnected
203
+ // class, since the connected mount always starts from an empty store.
204
+ const fetchAllCustomLists = jest.fn();
205
+ const fetchCustomListsForBook = jest.fn();
206
+ render(
207
+ <UnconnectedCustomListsForBook
208
+ csrfToken="token"
209
+ book={{ id: "id", title: "test title" } as any}
210
+ bookUrl="works/book url"
211
+ library="library"
212
+ refreshCatalog={jest.fn().mockResolvedValue(undefined)}
213
+ allCustomLists={allCustomLists as any}
214
+ customListsForBook={listsForBook as any}
215
+ fetchAllCustomLists={fetchAllCustomLists}
216
+ fetchCustomListsForBook={fetchCustomListsForBook}
217
+ editCustomListsForBook={jest.fn().mockResolvedValue(undefined)}
218
+ />
219
+ );
220
+
221
+ expect(fetchCustomListsForBook).toHaveBeenCalledTimes(1);
222
+ expect(fetchAllCustomLists).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it("adds a list and posts the updated set of lists", async () => {
226
+ const user = userEvent.setup();
227
+ const fetchSpy = stubFetch(allCustomLists, listsForBook);
228
+ const { container, refreshCatalog } = renderConnected();
229
+
230
+ await screen.findByRole("link", { name: "list 2" });
231
+
232
+ await user.selectOptions(
233
+ container.querySelector("select") as HTMLSelectElement,
234
+ "list 1"
235
+ );
236
+ await user.click(screen.getByRole("button", { name: "Add" }));
237
+
238
+ await waitFor(() => expect(refreshCatalog).toHaveBeenCalled());
239
+
240
+ const postCall = fetchSpy.mock.calls.find(
241
+ ([, opts]) => (opts as RequestInit)?.method === "POST"
242
+ );
243
+ expect(String(postCall[0])).toBe("admin/works/book url/lists");
244
+ const lists = JSON.parse(
245
+ ((postCall[1] as RequestInit).body as FormData).get("lists") as string
246
+ );
247
+ expect(lists).toStrictEqual([
248
+ { id: "2", name: "list 2", is_owner: true, is_shared: false },
249
+ { id: "1", name: "list 1", is_owner: true, is_shared: false },
250
+ ]);
251
+ });
252
+
253
+ it("removes a list and posts the emptied set of lists", async () => {
254
+ const user = userEvent.setup();
255
+ const fetchSpy = stubFetch(allCustomLists, listsForBook);
256
+ const { refreshCatalog } = renderConnected();
257
+
258
+ await screen.findByRole("link", { name: "list 2" });
259
+
260
+ await user.click(screen.getByRole("button", { name: "Delete" }));
261
+
262
+ await waitFor(() => expect(refreshCatalog).toHaveBeenCalled());
263
+
264
+ const postCall = fetchSpy.mock.calls.find(
265
+ ([, opts]) => (opts as RequestInit)?.method === "POST"
266
+ );
267
+ expect(String(postCall[0])).toBe("admin/works/book url/lists");
268
+ const lists = JSON.parse(
269
+ ((postCall[1] as RequestInit).body as FormData).get("lists") as string
270
+ );
271
+ expect(lists).toStrictEqual([]);
272
+ });
273
+
274
+ it("shows an error message", () => {
275
+ // A mount-time fetch failure would reject the (uncaught) fetch promise and
276
+ // crash the worker, so drive the error through the prop connect derives.
277
+ render(
278
+ <UnconnectedCustomListsForBook
279
+ csrfToken="token"
280
+ book={{ id: "id", title: "test title" } as any}
281
+ bookUrl="works/book url"
282
+ library="library"
283
+ refreshCatalog={jest.fn().mockResolvedValue(undefined)}
284
+ allCustomLists={allCustomLists as any}
285
+ customListsForBook={listsForBook as any}
286
+ fetchAllCustomLists={jest.fn()}
287
+ fetchCustomListsForBook={jest.fn()}
288
+ editCustomListsForBook={jest.fn().mockResolvedValue(undefined)}
289
+ fetchError={{ status: 400, response: "boom", url: "" } as any}
290
+ />
291
+ );
292
+ expect(screen.getByText(/Error: boom/)).toBeInTheDocument();
293
+ });
294
+
295
+ it("disables the remove and add controls while fetching", () => {
296
+ // isFetching is a connect-derived prop; inject it via the unconnected class.
297
+ render(
298
+ <UnconnectedCustomListsForBook
299
+ csrfToken="token"
300
+ book={{ id: "id", title: "test title" } as any}
301
+ bookUrl="works/book url"
302
+ library="library"
303
+ refreshCatalog={jest.fn().mockResolvedValue(undefined)}
304
+ allCustomLists={allCustomLists as any}
305
+ customListsForBook={listsForBook as any}
306
+ fetchAllCustomLists={jest.fn()}
307
+ fetchCustomListsForBook={jest.fn()}
308
+ editCustomListsForBook={jest.fn().mockResolvedValue(undefined)}
309
+ isFetching={true}
310
+ />
311
+ );
312
+ expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
313
+ expect(screen.getByRole("button", { name: "Add" })).toBeDisabled();
314
+ });
315
+
316
+ it("shows the new book's lists when it is rerendered for another book", () => {
317
+ const commonProps = {
318
+ csrfToken: "token",
319
+ library: "library",
320
+ refreshCatalog: jest.fn().mockResolvedValue(undefined),
321
+ allCustomLists: allCustomLists as any,
322
+ fetchAllCustomLists: jest.fn(),
323
+ fetchCustomListsForBook: jest.fn(),
324
+ editCustomListsForBook: jest.fn().mockResolvedValue(undefined),
325
+ };
326
+ const { rerender } = render(
327
+ <UnconnectedCustomListsForBook
328
+ {...commonProps}
329
+ book={{ id: "a", title: "Book A" } as any}
330
+ bookUrl="works/book-a"
331
+ customListsForBook={listsForBook as any}
332
+ />
333
+ );
334
+ // list 2 (the book's only list) is in the current column, which renders
335
+ // each list as a link; the add-menu renders options instead.
336
+ expect(screen.getByRole("link", { name: "list 2" })).toBeInTheDocument();
337
+
338
+ // The current column renders straight from customListsForBook, so a new
339
+ // book's lists replace the old ones on rerender.
340
+ rerender(
341
+ <UnconnectedCustomListsForBook
342
+ {...commonProps}
343
+ book={{ id: "b", title: "Book B" } as any}
344
+ bookUrl="works/book-b"
345
+ customListsForBook={[allCustomLists[0]] as any}
346
+ />
347
+ );
348
+ expect(screen.getByRole("link", { name: "list 1" })).toBeInTheDocument();
349
+ expect(
350
+ screen.queryByRole("link", { name: "list 2" })
351
+ ).not.toBeInTheDocument();
352
+ });
353
+ });
@@ -0,0 +1,141 @@
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 DiagnosticsServiceTabs from "../../../src/components/DiagnosticsServiceTabs";
6
+
7
+ describe("DiagnosticsServiceTabs", () => {
8
+ let goToTab: jest.Mock;
9
+
10
+ const ts1 = {
11
+ service: "test_service_1",
12
+ id: "1",
13
+ start: "start_time_string_1",
14
+ duration: "0",
15
+ collection_name: "collection1",
16
+ };
17
+ const ts2 = {
18
+ service: "test_service_2",
19
+ id: "2",
20
+ start: "start_time_string_2",
21
+ duration: "0",
22
+ collection_name: "collection2",
23
+ exception: "Stack trace",
24
+ };
25
+
26
+ const content: any = {
27
+ test_service_1: { collection1: [ts1] },
28
+ test_service_2: { collection2: [ts2] },
29
+ };
30
+
31
+ const renderTabs = () => {
32
+ goToTab = jest.fn();
33
+ return render(
34
+ <DiagnosticsServiceTabs
35
+ goToTab={goToTab}
36
+ content={content}
37
+ tab="test_service_1"
38
+ />
39
+ );
40
+ };
41
+
42
+ // Find the collapsible `.panel` whose header text matches `title`. The
43
+ // collection panels are titled by collection name; the timestamp panels nested
44
+ // inside them are titled by their start time.
45
+ const panelByTitle = (container: HTMLElement, title: string) => {
46
+ const titleEl = Array.from(container.querySelectorAll(".panel-title")).find(
47
+ (el) => el.textContent === title
48
+ );
49
+ return titleEl ? titleEl.closest(".panel") : null;
50
+ };
51
+
52
+ describe("nav", () => {
53
+ it("renders a tab for each service", () => {
54
+ const { container } = renderTabs();
55
+ expect(container.querySelectorAll("ul.nav-tabs")).toHaveLength(1);
56
+ const tabs = container.querySelectorAll("ul.nav-tabs li a");
57
+ expect(tabs).toHaveLength(2);
58
+ expect(tabs[0]).toHaveTextContent("Test_service_1");
59
+ expect(tabs[1]).toHaveTextContent("Test_service_2");
60
+ });
61
+
62
+ it("displays a badge", () => {
63
+ const { container } = renderTabs();
64
+ const tabs = container.querySelectorAll("ul.nav-tabs li");
65
+
66
+ // The first service has no exception: a plain count badge with a tooltip.
67
+ const tabWithoutException = tabs[0];
68
+ expect(tabWithoutException.querySelector(".danger")).toBeNull();
69
+ const badge = tabWithoutException.querySelector(".badge");
70
+ expect(badge).toHaveTextContent("1");
71
+ const toolTip = tabWithoutException.querySelector(".tool-tip");
72
+ expect(toolTip).toHaveTextContent(
73
+ "Total number of timestamps for this service"
74
+ );
75
+
76
+ // The second service contains a timestamp with an exception, so it shows a
77
+ // danger badge (a warning) and no tooltip.
78
+ const tabWithException = tabs[1];
79
+ const dangerBadge = tabWithException.querySelector(".badge.danger");
80
+ expect(dangerBadge).toHaveTextContent("!");
81
+ expect(tabWithException.querySelector(".tool-tip")).toBeNull();
82
+ });
83
+
84
+ it("defaults to showing the first tab", () => {
85
+ const { container } = renderTabs();
86
+ const tabs = container.querySelectorAll("ul.nav-tabs li");
87
+ expect(tabs[0]).toHaveClass("active");
88
+ expect(tabs[1]).not.toHaveClass("active");
89
+ });
90
+ });
91
+
92
+ describe("content", () => {
93
+ it("renders collections", () => {
94
+ const { container } = renderTabs();
95
+ expect(container.querySelectorAll(".tab-content")).toHaveLength(1);
96
+
97
+ // collection1 has no exception, so its panel starts collapsed.
98
+ const collection1 = panelByTitle(container, "collection1");
99
+ expect(collection1).toBeInTheDocument();
100
+ expect(collection1.querySelector(".panel-heading")).toHaveAttribute(
101
+ "aria-expanded",
102
+ "false"
103
+ );
104
+
105
+ // collection2 contains a timestamp with an exception, so its panel starts
106
+ // expanded.
107
+ const collection2 = panelByTitle(container, "collection2");
108
+ expect(collection2).toBeInTheDocument();
109
+ expect(collection2.querySelector(".panel-heading")).toHaveAttribute(
110
+ "aria-expanded",
111
+ "true"
112
+ );
113
+ });
114
+
115
+ it("renders timestamps", () => {
116
+ const { container } = renderTabs();
117
+ // Each timestamp renders as a panel headed by its start time.
118
+ expect(
119
+ panelByTitle(container, "start_time_string_1")
120
+ ).toBeInTheDocument();
121
+ expect(
122
+ panelByTitle(container, "start_time_string_2")
123
+ ).toBeInTheDocument();
124
+ // Both timestamps render their duration.
125
+ expect(screen.getAllByText("Duration: 0 seconds")).toHaveLength(2);
126
+ });
127
+ });
128
+
129
+ describe("behavior", () => {
130
+ it("switches tabs", async () => {
131
+ const user = userEvent.setup();
132
+ const { container } = renderTabs();
133
+ const tabLinks = container.querySelectorAll("ul.nav-tabs li a");
134
+
135
+ await user.click(tabLinks[1]);
136
+
137
+ expect(goToTab).toHaveBeenCalledTimes(1);
138
+ expect(goToTab).toHaveBeenCalledWith("test_service_2");
139
+ });
140
+ });
141
+ });
@@ -0,0 +1,75 @@
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 DiagnosticsServiceType from "../../../src/components/DiagnosticsServiceType";
6
+
7
+ describe("DiagnosticsServiceType", () => {
8
+ const ts1 = {
9
+ service: "test_service_1",
10
+ id: "1",
11
+ start: "start_time_string_1",
12
+ duration: "0",
13
+ collection_name: "collection1",
14
+ };
15
+ const ts2 = {
16
+ service: "test_service_2",
17
+ id: "2",
18
+ start: "start_time_string_2",
19
+ duration: "0",
20
+ collection_name: "collection2",
21
+ };
22
+
23
+ const services: any = {
24
+ test_service_1: { collection1: [ts1] },
25
+ test_service_2: { collection2: [ts2] },
26
+ };
27
+
28
+ it("renders service tabs", () => {
29
+ const { container } = render(
30
+ <DiagnosticsServiceType type="monitor" services={services} />
31
+ );
32
+
33
+ expect(container.firstChild).toHaveClass("config");
34
+ expect(container.firstChild).toHaveClass("services");
35
+
36
+ // The service tabs are rendered, one per service, defaulting to the first.
37
+ const tabs = container.querySelectorAll("ul.nav-tabs li");
38
+ expect(tabs).toHaveLength(2);
39
+ expect(tabs[0]).toHaveTextContent("Test_service_1");
40
+ expect(tabs[0]).toHaveClass("active");
41
+ expect(tabs[1]).toHaveTextContent("Test_service_2");
42
+ expect(tabs[1]).not.toHaveClass("active");
43
+ });
44
+
45
+ it("switches tabs", async () => {
46
+ const user = userEvent.setup();
47
+ const { container } = render(
48
+ <DiagnosticsServiceType type="monitor" services={services} />
49
+ );
50
+
51
+ // Starts on the first service tab.
52
+ let tabs = container.querySelectorAll("ul.nav-tabs li");
53
+ expect(tabs[0]).toHaveClass("active");
54
+ expect(tabs[1]).not.toHaveClass("active");
55
+
56
+ // Clicking the second service tab selects it (goToTab updates the state that
57
+ // drives which tab is active).
58
+ await user.click(container.querySelectorAll("ul.nav-tabs li a")[1]);
59
+
60
+ tabs = container.querySelectorAll("ul.nav-tabs li");
61
+ expect(tabs[0]).not.toHaveClass("active");
62
+ expect(tabs[1]).toHaveClass("active");
63
+ });
64
+
65
+ it("displays a message if there are no services", () => {
66
+ const { container } = render(
67
+ <DiagnosticsServiceType type="monitor" services={null} />
68
+ );
69
+
70
+ expect(container.querySelector("ul.nav-tabs")).toBeNull();
71
+ expect(
72
+ screen.getByText("There are currently no monitor services.")
73
+ ).toBeInTheDocument();
74
+ });
75
+ });