@thepalaceproject/circulation-admin 1.46.0 → 1.47.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.
@@ -4,6 +4,7 @@ import { render, within } from "@testing-library/react";
4
4
  import userEvent from "@testing-library/user-event";
5
5
 
6
6
  import ServiceEditForm, {
7
+ libraryRefKey,
7
8
  ServiceEditFormProps,
8
9
  ServiceEditFormState,
9
10
  } from "../../../src/components/ServiceEditForm";
@@ -483,6 +484,91 @@ describe("ServiceEditForm", () => {
483
484
  expect(container.querySelector(".with-edit-button a")).toBeNull();
484
485
  });
485
486
 
487
+ it("shows a loading indicator in the Libraries panel until allLibraries arrives", () => {
488
+ // Undefined allLibraries means the library list is still loading.
489
+ const dataStillLoading = Object.assign({}, servicesData, {
490
+ allLibraries: undefined,
491
+ });
492
+ const { container, rerender } = renderForm({
493
+ data: dataStillLoading,
494
+ item: serviceData,
495
+ });
496
+ expect(container.querySelector(".update-libraries")).toBeNull();
497
+ expect(container.querySelector('[role="status"]')).toHaveTextContent(
498
+ "Loading libraries..."
499
+ );
500
+
501
+ rerenderForm(rerender, { item: serviceData });
502
+ // The status line stays mounted and announces completion; the
503
+ // completion text is visually hidden.
504
+ const status = container.querySelector('[role="status"]');
505
+ expect(status).toHaveTextContent("Libraries loaded.");
506
+ expect(status).toHaveClass("visuallyHidden");
507
+ const editable = container.querySelectorAll(".with-edit-button");
508
+ expect(editable).toHaveLength(1);
509
+ expect(editable[0]).toHaveTextContent("New York Public Library - nypl");
510
+ });
511
+
512
+ it("explains a failed library list load in the Libraries panel", () => {
513
+ // On failure allLibraries settles to [] and allLibrariesError is set.
514
+ const dataWithError = Object.assign({}, servicesData, {
515
+ allLibraries: [],
516
+ allLibrariesError: {
517
+ status: 500,
518
+ response: "nope",
519
+ url: "/admin/libraries",
520
+ },
521
+ });
522
+ const { container } = renderForm({
523
+ data: dataWithError,
524
+ item: serviceData,
525
+ });
526
+ expect(container.querySelector(".alert-danger")).toHaveTextContent(
527
+ "The library list failed to load"
528
+ );
529
+ // The role="alert" danger Alert announces the failure itself, so the
530
+ // status region empties rather than duplicating the announcement.
531
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
532
+ // The associated library still renders, by short name.
533
+ const editable = container.querySelectorAll(".with-edit-button");
534
+ expect(editable).toHaveLength(1);
535
+ expect(editable[0]).toHaveTextContent("nypl");
536
+ // A removal could not be undone in-session, so it is disabled too.
537
+ expect(container.querySelector("button.remove-btn")).toBeDisabled();
538
+ });
539
+
540
+ it("warns when the library list could not be refreshed", () => {
541
+ const staleData = Object.assign({}, servicesData, {
542
+ allLibrariesRefreshError: {
543
+ status: 500,
544
+ response: "nope",
545
+ url: "/admin/libraries",
546
+ },
547
+ });
548
+ const { container } = renderForm({ data: staleData, item: serviceData });
549
+ expect(container.querySelector(".alert-warning")).toHaveTextContent(
550
+ "The library list could not be refreshed"
551
+ );
552
+ // The role="alert" warning announces the refresh failure itself, so
553
+ // the status region empties rather than duplicating the announcement.
554
+ expect(container.querySelector('[role="status"]')).toBeEmptyDOMElement();
555
+ // The stale list still renders in full, links included, and the
556
+ // panel stays editable; only the blocking error disables removal.
557
+ const editable = container.querySelectorAll(".with-edit-button");
558
+ expect(editable).toHaveLength(1);
559
+ expect(editable[0]).toHaveTextContent("New York Public Library - nypl");
560
+ expect(container.querySelector(".alert-danger")).toBeNull();
561
+ expect(container.querySelector("button.remove-btn")).toBeEnabled();
562
+ });
563
+
564
+ it("says when no libraries are configured", () => {
565
+ const emptyData = Object.assign({}, servicesData, { allLibraries: [] });
566
+ const { container } = renderForm({ data: emptyData });
567
+ expect(container.querySelector(".update-libraries")).toHaveTextContent(
568
+ "No libraries are configured."
569
+ );
570
+ });
571
+
486
572
  it("renders removable and editable libraries", () => {
487
573
  const { container, unmount } = renderForm();
488
574
  expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
@@ -896,6 +982,27 @@ describe("ServiceEditForm", () => {
896
982
  expect(libraries).toHaveLength(0);
897
983
  });
898
984
 
985
+ it("removes an expanded library and closes its settings", async () => {
986
+ const user = userEvent.setup();
987
+ const { container } = renderForm({ item: serviceData });
988
+ const libraryItem = container.querySelector(
989
+ ".with-remove-button"
990
+ ) as HTMLElement;
991
+
992
+ await user.click(
993
+ libraryItem.querySelector("button.edit-btn") as HTMLElement
994
+ );
995
+ expect(
996
+ container.querySelector(".edit-library-settings")
997
+ ).toBeInTheDocument();
998
+
999
+ await user.click(
1000
+ libraryItem.querySelector("button.remove-btn") as HTMLElement
1001
+ );
1002
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
1003
+ expect(container.querySelector(".edit-library-settings")).toBeNull();
1004
+ });
1005
+
899
1006
  it("edits a library", async () => {
900
1007
  const user = userEvent.setup();
901
1008
  const { container } = renderForm({ item: serviceData });
@@ -978,6 +1085,15 @@ describe("ServiceEditForm", () => {
978
1085
  ).toBe("option3");
979
1086
  });
980
1087
 
1088
+ it("builds library ref keys for add and edit mode", () => {
1089
+ expect(libraryRefKey("key", "add")).toBe("key");
1090
+ expect(libraryRefKey("key", "edit", "nypl")).toBe("nypl_key");
1091
+ // Compile-time guard: ts-jest fails this file if the overloads stop
1092
+ // rejecting an edit-mode call with no short name.
1093
+ // @ts-expect-error edit mode must pass the library short name
1094
+ libraryRefKey("key", "edit");
1095
+ });
1096
+
981
1097
  it("calls save when the form is submitted", async () => {
982
1098
  // The reusable Form wires submission solely through its submit button's
983
1099
  // onClick, so clicking it is the only way to submit; assert the resulting
@@ -1059,6 +1175,28 @@ describe("ServiceEditForm", () => {
1059
1175
  expect(nameInput().value).toBe("");
1060
1176
  });
1061
1177
 
1178
+ it("clears every associated library after a successful save (PP-5153)", () => {
1179
+ const threeLibraries = {
1180
+ ...serviceData,
1181
+ libraries: [
1182
+ { short_name: "nypl" },
1183
+ { short_name: "bpl" },
1184
+ { short_name: "qpl" },
1185
+ ],
1186
+ };
1187
+ const { container, rerender } = renderForm({ item: threeLibraries });
1188
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(3);
1189
+
1190
+ // clearForm fires onRemove once per library inside one React batch.
1191
+ // Each removal must build on the previous one, otherwise only the last
1192
+ // one survives and the other libraries stay in the list.
1193
+ rerenderForm(rerender, {
1194
+ item: threeLibraries,
1195
+ responseBody: "new service",
1196
+ });
1197
+ expect(container.querySelectorAll(".with-remove-button")).toHaveLength(0);
1198
+ });
1199
+
1062
1200
  it("doesn't clear the form if there's an error message", async () => {
1063
1201
  const user = userEvent.setup();
1064
1202
  const { container, rerender } = renderForm();
@@ -6,13 +6,16 @@ import SetupPage from "../../../src/components/SetupPage";
6
6
 
7
7
  describe("SetupPage", () => {
8
8
  beforeEach(() => {
9
- // SetupPage renders the connected IndividualAdmins list, which fetches the
10
- // admin list on mount. Stub fetch so mounting does not hit the network.
11
- jest.spyOn(globalThis, "fetch").mockResolvedValue(
12
- new Response(JSON.stringify({ individualAdmins: [] }), {
13
- status: 200,
14
- headers: { "Content-Type": "application/json" },
15
- })
9
+ // SetupPage renders the connected IndividualAdmins list, which fetches on
10
+ // mount. Stub fetch so mounting does not hit the network; a Response body
11
+ // can only be read once, so build one per call.
12
+ jest.spyOn(globalThis, "fetch").mockImplementation(() =>
13
+ Promise.resolve(
14
+ new Response(JSON.stringify({ individualAdmins: [] }), {
15
+ status: 200,
16
+ headers: { "Content-Type": "application/json" },
17
+ })
18
+ )
16
19
  );
17
20
  });
18
21
 
@@ -37,5 +40,19 @@ describe("SetupPage", () => {
37
40
  name: "Set up your system admin account",
38
41
  })
39
42
  ).toBeInTheDocument();
43
+
44
+ // settingUp skips the libraries request, which cannot succeed before an
45
+ // admin exists. The positive check keeps the negative one honest.
46
+ const urls = (globalThis.fetch as jest.Mock).mock.calls.map((call) =>
47
+ String(call[0])
48
+ );
49
+ expect(urls).toEqual(
50
+ expect.arrayContaining([
51
+ expect.stringContaining("/admin/individual_admins"),
52
+ ])
53
+ );
54
+ expect(urls).not.toEqual(
55
+ expect.arrayContaining([expect.stringContaining("/admin/libraries")])
56
+ );
40
57
  });
41
58
  });
@@ -0,0 +1,171 @@
1
+ import libraries from "../../../src/reducers/libraries";
2
+ import ActionCreator from "../../../src/actions";
3
+
4
+ const REQUEST = `${ActionCreator.LIBRARIES}_${ActionCreator.REQUEST}`;
5
+ const FAILURE = `${ActionCreator.LIBRARIES}_${ActionCreator.FAILURE}`;
6
+ const SUCCESS = `${ActionCreator.LIBRARIES}_${ActionCreator.SUCCESS}`;
7
+ const LOAD = `${ActionCreator.LIBRARIES}_${ActionCreator.LOAD}`;
8
+ const EDIT_REQUEST = `${ActionCreator.EDIT_LIBRARY}_${ActionCreator.REQUEST}`;
9
+ const EDIT_SUCCESS = `${ActionCreator.EDIT_LIBRARY}_${ActionCreator.SUCCESS}`;
10
+
11
+ describe("libraries reducer", () => {
12
+ const fetchError = { status: 500, response: "nope", url: "/admin/libraries" };
13
+
14
+ it("clears state on a first-load request", () => {
15
+ const state = libraries(undefined, { type: REQUEST });
16
+ expect(state.isFetching).toBe(true);
17
+ expect(state.isLoaded).toBe(false);
18
+ expect(state.fetchError).toBeNull();
19
+ });
20
+
21
+ it("moves the previous failure to lastFetchError while a retry is in flight", () => {
22
+ const failed = libraries(undefined, { type: FAILURE, error: fetchError });
23
+ expect(failed.fetchError).toEqual(fetchError);
24
+ expect(failed.isLoaded).toBe(true);
25
+
26
+ const retrying = libraries(failed, { type: REQUEST });
27
+ expect(retrying.isFetching).toBe(true);
28
+ // fetchError keeps meaning "the current request failed"; the old
29
+ // failure moves to lastFetchError so consumers can keep showing it.
30
+ expect(retrying.fetchError).toBeNull();
31
+ expect(retrying.lastFetchError).toEqual(fetchError);
32
+ expect(retrying.isLoaded).toBe(false);
33
+ });
34
+
35
+ it("retains the loaded list as lastData while a refetch is in flight", () => {
36
+ const data = { libraries: [{ short_name: "nypl" }] };
37
+ let state = libraries(undefined, { type: LOAD, data });
38
+ state = libraries(state, { type: REQUEST });
39
+
40
+ expect(state.isFetching).toBe(true);
41
+ // data keeps its normal lifecycle (nulled by REQUEST) for the Libraries
42
+ // config page; the retained copy moves to lastData.
43
+ expect(state.data).toBeNull();
44
+ expect(state.isLoaded).toBe(false);
45
+ expect(state.lastData).toEqual(data);
46
+ });
47
+
48
+ it("keeps lastData through a failed refetch and clears it on a fresh load", () => {
49
+ const data = { libraries: [{ short_name: "nypl" }] };
50
+ let state = libraries(undefined, { type: LOAD, data });
51
+ state = libraries(state, { type: REQUEST });
52
+ state = libraries(state, { type: FAILURE, error: fetchError });
53
+ expect(state.lastData).toEqual(data);
54
+ expect(state.fetchError).toEqual(fetchError);
55
+
56
+ state = libraries(state, { type: REQUEST });
57
+ state = libraries(state, { type: SUCCESS });
58
+ state = libraries(state, { type: LOAD, data });
59
+ expect(state.lastData).toBeNull();
60
+ expect(state.data).toEqual(data);
61
+ });
62
+
63
+ it("drops a failure recorded beside current data when a refetch starts", () => {
64
+ // Overlapping requests with mixed outcomes leave data plus fetchError.
65
+ // Consumers ignore that failure, so it must not resurface against the
66
+ // retained copy while the next refetch runs.
67
+ const data = { libraries: [{ short_name: "nypl" }] };
68
+ let state = libraries(undefined, { type: LOAD, data });
69
+ state = libraries(state, { type: FAILURE, error: fetchError });
70
+ expect(state.data).toEqual(data);
71
+ expect(state.fetchError).toEqual(fetchError);
72
+
73
+ state = libraries(state, { type: REQUEST });
74
+ expect(state.lastData).toEqual(data);
75
+ expect(state.lastFetchError).toBeNull();
76
+ });
77
+
78
+ it("keeps lastFetchError when a second request starts before the retry settles", () => {
79
+ // The header's fetch and the Libraries tab's fetch can overlap.
80
+ let state = libraries(undefined, { type: FAILURE, error: fetchError });
81
+ state = libraries(state, { type: REQUEST });
82
+ state = libraries(state, { type: REQUEST });
83
+
84
+ expect(state.fetchError).toBeNull();
85
+ expect(state.lastFetchError).toEqual(fetchError);
86
+ });
87
+
88
+ it("keeps lastFetchError through a retry's SUCCESS until LOAD", () => {
89
+ let state = libraries(undefined, { type: FAILURE, error: fetchError });
90
+ state = libraries(state, { type: REQUEST });
91
+ state = libraries(state, { type: SUCCESS });
92
+
93
+ expect(state.data).toBeNull();
94
+ expect(state.isLoaded).toBe(false);
95
+ expect(state.fetchError).toBeNull();
96
+ expect(state.lastFetchError).toEqual(fetchError);
97
+ });
98
+
99
+ it("clears the old failure once a retry succeeds", () => {
100
+ const data = { libraries: [{ short_name: "nypl" }] };
101
+ let state = libraries(undefined, { type: FAILURE, error: fetchError });
102
+ state = libraries(state, { type: REQUEST });
103
+ state = libraries(state, { type: SUCCESS });
104
+ state = libraries(state, { type: LOAD, data });
105
+
106
+ expect(state.fetchError).toBeNull();
107
+ expect(state.lastFetchError).toBeNull();
108
+ expect(state.isLoaded).toBe(true);
109
+ expect(state.data).toEqual(data);
110
+ });
111
+
112
+ it("keeps a list-fetch failure when a library form submit starts", () => {
113
+ // EDIT_LIBRARY_REQUEST shares this reducer and clears fetchError; the
114
+ // failure must survive as lastFetchError so consumers do not mistake
115
+ // the state for a cleanly loaded list.
116
+ let state = libraries(undefined, { type: FAILURE, error: fetchError });
117
+ state = libraries(state, { type: EDIT_REQUEST });
118
+
119
+ expect(state.fetchError).toBeNull();
120
+ expect(state.lastFetchError).toEqual(fetchError);
121
+ });
122
+
123
+ it("keeps an already-retained failure when a library form submit starts", () => {
124
+ let state = libraries(undefined, { type: FAILURE, error: fetchError });
125
+ state = libraries(state, { type: REQUEST });
126
+ state = libraries(state, { type: EDIT_REQUEST });
127
+
128
+ expect(state.lastFetchError).toEqual(fetchError);
129
+ });
130
+
131
+ it("does not invent a failure when a library form submit starts cleanly", () => {
132
+ const state = libraries(undefined, { type: EDIT_REQUEST });
133
+ expect(state.lastFetchError).toBeNull();
134
+ });
135
+
136
+ it("keeps a list-fetch failure when a library form submit succeeds", () => {
137
+ // A list refetch can fail between EDIT_LIBRARY_REQUEST and its
138
+ // SUCCESS, and the base reducer's SUCCESS also clears fetchError.
139
+ let state = libraries(undefined, { type: REQUEST });
140
+ state = libraries(state, { type: EDIT_REQUEST });
141
+ state = libraries(state, { type: FAILURE, error: fetchError });
142
+ state = libraries(state, { type: EDIT_SUCCESS });
143
+
144
+ expect(state.fetchError).toBeNull();
145
+ expect(state.lastFetchError).toEqual(fetchError);
146
+ });
147
+
148
+ it("drops a failure beside current data when a library form submit starts", () => {
149
+ // Same rule as LIBRARIES_REQUEST: consumers ignore that failure.
150
+ const data = { libraries: [{ short_name: "nypl" }] };
151
+ let state = libraries(undefined, { type: LOAD, data });
152
+ state = libraries(state, { type: FAILURE, error: fetchError });
153
+ state = libraries(state, { type: EDIT_REQUEST });
154
+
155
+ expect(state.lastFetchError).toBeNull();
156
+ });
157
+
158
+ it("reports only the new failure when a retry fails again", () => {
159
+ const newError = {
160
+ status: 502,
161
+ response: "worse",
162
+ url: "/admin/libraries",
163
+ };
164
+ let state = libraries(undefined, { type: FAILURE, error: fetchError });
165
+ state = libraries(state, { type: REQUEST });
166
+ state = libraries(state, { type: FAILURE, error: newError });
167
+
168
+ expect(state.fetchError).toEqual(newError);
169
+ expect(state.lastFetchError).toBeNull();
170
+ });
171
+ });
@@ -0,0 +1,235 @@
1
+ import {
2
+ fetchLibrariesIfNeeded,
3
+ settledAllLibraries,
4
+ } from "../../../src/utils/allLibraries";
5
+
6
+ describe("settledAllLibraries", () => {
7
+ const libraries = [{ short_name: "nypl", name: "New York Public Library" }];
8
+ const stateWith = (librariesState) => ({
9
+ editor: { libraries: librariesState },
10
+ });
11
+
12
+ it("returns nothing while the request has not settled", () => {
13
+ expect(settledAllLibraries(stateWith(undefined))).toEqual({});
14
+ expect(
15
+ settledAllLibraries(
16
+ stateWith({ data: null, isLoaded: false, isFetching: true })
17
+ )
18
+ ).toEqual({});
19
+ });
20
+
21
+ it("reports not settled when isLoaded is set without a list or error", () => {
22
+ // EDIT_LIBRARY actions can clear fetchError while leaving isLoaded
23
+ // true; that state proves nothing worth showing.
24
+ expect(settledAllLibraries(stateWith({ isLoaded: true }))).toEqual({});
25
+ });
26
+
27
+ it("settles an empty loaded list", () => {
28
+ expect(
29
+ settledAllLibraries(
30
+ stateWith({ data: { libraries: [] }, isLoaded: true })
31
+ )
32
+ ).toEqual({
33
+ allLibraries: [],
34
+ allLibrariesError: undefined,
35
+ allLibrariesRefreshError: undefined,
36
+ });
37
+ });
38
+
39
+ it("returns the loaded list", () => {
40
+ expect(
41
+ settledAllLibraries(
42
+ stateWith({ data: { libraries }, isLoaded: true, fetchError: null })
43
+ )
44
+ ).toEqual({ allLibraries: libraries, allLibrariesError: undefined });
45
+ });
46
+
47
+ it("serves the retained list while a refetch is in flight", () => {
48
+ expect(
49
+ settledAllLibraries(
50
+ stateWith({
51
+ data: null,
52
+ isLoaded: false,
53
+ isFetching: true,
54
+ lastData: { libraries },
55
+ })
56
+ )
57
+ ).toEqual({
58
+ allLibraries: libraries,
59
+ allLibrariesError: undefined,
60
+ allLibrariesRefreshError: undefined,
61
+ });
62
+ });
63
+
64
+ it("serves the retained list plus a refresh error after a failed refetch", () => {
65
+ const fetchError = {
66
+ status: 500,
67
+ response: "nope",
68
+ url: "/admin/libraries",
69
+ };
70
+ expect(
71
+ settledAllLibraries(
72
+ stateWith({
73
+ data: null,
74
+ isLoaded: true,
75
+ fetchError,
76
+ lastData: { libraries },
77
+ })
78
+ )
79
+ ).toEqual({
80
+ allLibraries: libraries,
81
+ allLibrariesError: undefined,
82
+ allLibrariesRefreshError: fetchError,
83
+ });
84
+ });
85
+
86
+ it("ignores a failure recorded beside a current list", () => {
87
+ // Overlapping requests with mixed outcomes (one LOAD, one FAILURE) can
88
+ // leave a current list next to a recorded failure. The list on screen
89
+ // is up to date, so no error of either kind is reported.
90
+ const fetchError = {
91
+ status: 500,
92
+ response: "nope",
93
+ url: "/admin/libraries",
94
+ };
95
+ expect(
96
+ settledAllLibraries(
97
+ stateWith({ data: { libraries }, isLoaded: true, fetchError })
98
+ )
99
+ ).toEqual({
100
+ allLibraries: libraries,
101
+ allLibrariesError: undefined,
102
+ allLibrariesRefreshError: undefined,
103
+ });
104
+ });
105
+
106
+ it("settles to an empty list plus the error on failure", () => {
107
+ const fetchError = {
108
+ status: 500,
109
+ response: "nope",
110
+ url: "/admin/libraries",
111
+ };
112
+ expect(
113
+ settledAllLibraries(stateWith({ data: null, isLoaded: true, fetchError }))
114
+ ).toEqual({ allLibraries: [], allLibrariesError: fetchError });
115
+ });
116
+
117
+ it("keeps a previous failure settled while a retry is in flight", () => {
118
+ // The reducer moves the old failure to lastFetchError during a retry.
119
+ const lastFetchError = {
120
+ status: 500,
121
+ response: "nope",
122
+ url: "/admin/libraries",
123
+ };
124
+ expect(
125
+ settledAllLibraries(
126
+ stateWith({
127
+ data: null,
128
+ isLoaded: false,
129
+ isFetching: true,
130
+ fetchError: null,
131
+ lastFetchError,
132
+ })
133
+ )
134
+ ).toEqual({ allLibraries: [], allLibrariesError: lastFetchError });
135
+ });
136
+ });
137
+
138
+ describe("fetchLibrariesIfNeeded", () => {
139
+ const fetchThunk = "libraries thunk";
140
+ const actions = { fetchLibraries: jest.fn(() => fetchThunk) } as any;
141
+
142
+ // A dispatch that runs thunks against the given libraries state and
143
+ // records every other dispatched action.
144
+ const makeDispatch = (librariesState) => {
145
+ const dispatched = [];
146
+ const getState = () => ({ editor: { libraries: librariesState } });
147
+ const dispatch = (action) => {
148
+ if (typeof action === "function") {
149
+ return action(dispatch, getState);
150
+ }
151
+ dispatched.push(action);
152
+ return Promise.resolve();
153
+ };
154
+ return { dispatch, dispatched };
155
+ };
156
+
157
+ beforeEach(() => jest.clearAllMocks());
158
+
159
+ it("fetches when the list has never loaded", () => {
160
+ const { dispatch, dispatched } = makeDispatch(undefined);
161
+ fetchLibrariesIfNeeded(dispatch, actions);
162
+ expect(dispatched).toEqual([fetchThunk]);
163
+ });
164
+
165
+ it("does not fetch when the list is already loaded", () => {
166
+ const { dispatch, dispatched } = makeDispatch({
167
+ data: { libraries: [] },
168
+ isLoaded: true,
169
+ isFetching: false,
170
+ fetchError: null,
171
+ });
172
+ fetchLibrariesIfNeeded(dispatch, actions);
173
+ expect(dispatched).toEqual([]);
174
+ });
175
+
176
+ it("fetches when isLoaded is set without a list or error", () => {
177
+ // EDIT_LIBRARY actions can clear fetchError while leaving isLoaded
178
+ // true; there is nothing to show, so fetch.
179
+ const { dispatch, dispatched } = makeDispatch({
180
+ isLoaded: true,
181
+ isFetching: false,
182
+ fetchError: null,
183
+ });
184
+ fetchLibrariesIfNeeded(dispatch, actions);
185
+ expect(dispatched).toEqual([fetchThunk]);
186
+ });
187
+
188
+ it("retries when a retained list carries a retained failure", () => {
189
+ const { dispatch, dispatched } = makeDispatch({
190
+ data: null,
191
+ isLoaded: false,
192
+ isFetching: false,
193
+ fetchError: null,
194
+ lastData: { libraries: [] },
195
+ lastFetchError: {
196
+ status: 500,
197
+ response: "nope",
198
+ url: "/admin/libraries",
199
+ },
200
+ });
201
+ fetchLibrariesIfNeeded(dispatch, actions);
202
+ expect(dispatched).toEqual([fetchThunk]);
203
+ });
204
+
205
+ it("does not fetch while a request is already in flight", () => {
206
+ const { dispatch, dispatched } = makeDispatch({
207
+ isLoaded: false,
208
+ isFetching: true,
209
+ fetchError: null,
210
+ });
211
+ fetchLibrariesIfNeeded(dispatch, actions);
212
+ expect(dispatched).toEqual([]);
213
+ });
214
+
215
+ it("retries when a loaded list has a recorded failure", () => {
216
+ const { dispatch, dispatched } = makeDispatch({
217
+ data: { libraries: [] },
218
+ isLoaded: true,
219
+ isFetching: false,
220
+ fetchError: { status: 500, response: "nope", url: "/admin/libraries" },
221
+ });
222
+ fetchLibrariesIfNeeded(dispatch, actions);
223
+ expect(dispatched).toEqual([fetchThunk]);
224
+ });
225
+
226
+ it("retries after a failed request", () => {
227
+ const { dispatch, dispatched } = makeDispatch({
228
+ isLoaded: true,
229
+ isFetching: false,
230
+ fetchError: { status: 500, response: "nope", url: "/admin/libraries" },
231
+ });
232
+ fetchLibrariesIfNeeded(dispatch, actions);
233
+ expect(dispatched).toEqual([fetchThunk]);
234
+ });
235
+ });