@thepalaceproject/circulation-admin 1.46.0-post.1 → 1.46.0-post.3
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 +2 -2
- package/dist/circulation-admin.js +1 -1
- package/package.json +1 -1
- package/tests/jest/components/DiscoveryServices.test.tsx +36 -0
- package/tests/jest/components/EditableConfigList.test.tsx +75 -1
- package/tests/jest/components/IndividualAdminEditForm.test.tsx +98 -0
- package/tests/jest/components/IndividualAdmins.test.tsx +92 -4
- package/tests/jest/components/LibraryRegistration.test.tsx +18 -0
- package/tests/jest/components/PatronAuthServiceEditForm.test.tsx +13 -5
- package/tests/jest/components/PatronAuthServices.test.tsx +15 -0
- package/tests/jest/components/ServiceEditForm.test.tsx +95 -0
- package/tests/jest/components/SetupPage.test.tsx +24 -7
- package/tests/jest/reducers/libraries.test.ts +171 -0
- package/tests/jest/utils/allLibraries.test.ts +235 -0
|
@@ -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
|
+
});
|