@thepalaceproject/circulation-admin 1.21.0 → 1.22.0-post.1

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
@@ -61,8 +61,8 @@
61
61
  "react-redux": "^7.2.9",
62
62
  "react-router": "^3.2.0",
63
63
  "recharts": "^1.8.6",
64
- "redux": "^4.0.1",
65
- "redux-thunk": "^2.3.0",
64
+ "redux": "^4.2.1",
65
+ "redux-thunk": "^2.4.2",
66
66
  "request": "^2.85.0",
67
67
  "stream-browserify": "^3.0.0",
68
68
  "timers-browserify": "^2.0.12",
@@ -100,7 +100,7 @@
100
100
  "eslint-plugin-prettier": "^3.1.3",
101
101
  "eslint-plugin-react": "^7.19.0",
102
102
  "eslint-plugin-react-hooks": "^4.0.0",
103
- "fetch-mock": "^7.3.1",
103
+ "fetch-mock": "^10.0.7",
104
104
  "fetch-mock-jest": "^1.5.1",
105
105
  "fetch-ponyfill": "^7.1.0",
106
106
  "file-loader": "^6.2.0",
@@ -149,5 +149,5 @@
149
149
  "*.{js,jsx,ts,tsx,css,md}": "prettier --write",
150
150
  "*.{js,css,md}": "prettier --write"
151
151
  },
152
- "version": "1.21.0"
152
+ "version": "1.22.0-post.1"
153
153
  }
@@ -1,6 +1,7 @@
1
1
  import { StatisticsData } from "../../src/interfaces";
2
2
 
3
3
  export const testLibraryKey = "lyrasis-reads";
4
+ export const testLibraryName = "LYRASIS Reads";
4
5
  export const noCollectionsLibraryKey = "unfunded-library";
5
6
  export const noInventoryLibraryKey = "unfunded-library";
6
7
  export const noPatronsLibraryKey = "unused-library";
@@ -0,0 +1,235 @@
1
+ import * as React from "react";
2
+ import { renderWithProviders } from "../testUtils/withProviders";
3
+ import userEvent from "@testing-library/user-event";
4
+ import {
5
+ bookEditorApiEndpoints,
6
+ PER_LIBRARY_SUPPRESS_REL,
7
+ PER_LIBRARY_UNSUPPRESS_REL,
8
+ } from "../../../src/features/book/bookEditorSlice";
9
+ import { BookDetailsEditor } from "../../../src/components/BookDetailsEditor";
10
+ import { expect } from "chai";
11
+ import { store } from "../../../src/store";
12
+ import * as fetchMock from "fetch-mock-jest";
13
+
14
+ describe("BookDetails", () => {
15
+ const suppressPerLibraryLink = {
16
+ href: "/suppress/href",
17
+ rel: PER_LIBRARY_SUPPRESS_REL,
18
+ };
19
+ const unsuppressPerLibraryLink = {
20
+ href: "/unsuppress/href",
21
+ rel: PER_LIBRARY_UNSUPPRESS_REL,
22
+ };
23
+
24
+ let fetchBookData;
25
+ let fetchRoles;
26
+ let fetchMedia;
27
+ let fetchLanguages;
28
+ let postBookData;
29
+ let dispatchProps;
30
+ const suppressBook = jest.fn().mockImplementation((url: string) =>
31
+ store.dispatch(
32
+ bookEditorApiEndpoints.endpoints.suppressBook.initiate({
33
+ url,
34
+ csrfToken: "token",
35
+ })
36
+ )
37
+ );
38
+ const unsuppressBook = jest.fn().mockImplementation((url: string) =>
39
+ store.dispatch(
40
+ bookEditorApiEndpoints.endpoints.unsuppressBook.initiate({
41
+ url,
42
+ csrfToken: "token",
43
+ })
44
+ )
45
+ );
46
+
47
+ beforeAll(() => {
48
+ fetchMock
49
+ .post("/suppress/href", {
50
+ status: 200,
51
+ body: { message: "Successfully suppressed book availability." },
52
+ })
53
+ .delete("/unsuppress/href", {
54
+ status: 200,
55
+ body: { message: "Successfully unsuppressed book availability." },
56
+ });
57
+ });
58
+ beforeEach(() => {
59
+ fetchBookData = jest.fn();
60
+ fetchRoles = jest.fn();
61
+ fetchMedia = jest.fn();
62
+ fetchLanguages = jest.fn();
63
+ postBookData = jest.fn();
64
+ dispatchProps = {
65
+ fetchBookData,
66
+ fetchRoles,
67
+ fetchMedia,
68
+ fetchLanguages,
69
+ postBookData,
70
+ suppressBook,
71
+ unsuppressBook,
72
+ };
73
+ });
74
+ afterEach(() => {
75
+ jest.clearAllMocks();
76
+ fetchMock.resetHistory();
77
+ });
78
+ afterAll(() => {
79
+ jest.restoreAllMocks();
80
+ fetchMock.restore();
81
+ });
82
+
83
+ it("don't show hide button if not a library's admin", () => {
84
+ const { queryByRole } = renderWithProviders(
85
+ <BookDetailsEditor
86
+ bookData={{ id: "id", title: "title", suppressPerLibraryLink }}
87
+ bookUrl="url"
88
+ csrfToken="token"
89
+ canSuppress={false}
90
+ {...dispatchProps}
91
+ />
92
+ );
93
+ const hideButton = queryByRole("button", { name: "Hide" });
94
+ const restoreButton = queryByRole("button", { name: "Restore" });
95
+
96
+ expect(hideButton).to.be.null;
97
+ expect(restoreButton).to.be.null;
98
+ });
99
+
100
+ it("don't show restore button if not a library's admin", () => {
101
+ const { queryByRole } = renderWithProviders(
102
+ <BookDetailsEditor
103
+ bookData={{ id: "id", title: "title", unsuppressPerLibraryLink }}
104
+ bookUrl="url"
105
+ csrfToken="token"
106
+ canSuppress={false}
107
+ {...dispatchProps}
108
+ />
109
+ );
110
+ const hideButton = queryByRole("button", { name: "Hide" });
111
+ const restoreButton = queryByRole("button", { name: "Restore" });
112
+
113
+ expect(hideButton).to.be.null;
114
+ expect(restoreButton).to.be.null;
115
+ });
116
+
117
+ it("uses modal for suppress book confirmation", async () => {
118
+ // Configure standard constructors so that RTK Query works in tests with FetchMockJest
119
+ Object.assign(fetchMock.config, {
120
+ fetch,
121
+ Headers,
122
+ Request,
123
+ Response,
124
+ });
125
+
126
+ const user = userEvent.setup();
127
+
128
+ const { getByRole, getByText, queryByRole } = renderWithProviders(
129
+ <BookDetailsEditor
130
+ bookData={{ id: "id", title: "title", suppressPerLibraryLink }}
131
+ bookUrl="url"
132
+ csrfToken="token"
133
+ canSuppress={true}
134
+ {...dispatchProps}
135
+ />
136
+ );
137
+
138
+ // The `Hide` button should be present.
139
+ const hideButton = getByRole("button", { name: "Hide" });
140
+
141
+ // Clicking `Hide` should show the book suppression modal.
142
+ await user.click(hideButton);
143
+ getByRole("heading", { level: 4, name: "Suppressing Availability" });
144
+ getByText(/to hide this title from your library's catalog/);
145
+ let confirmButton = getByRole("button", { name: "Suppress Availability" });
146
+ let cancelButton = getByRole("button", { name: "Cancel" });
147
+
148
+ // Clicking `Cancel` should close the modal.
149
+ await user.click(cancelButton);
150
+ confirmButton = queryByRole("button", { name: "Suppress Availability" });
151
+ cancelButton = queryByRole("button", { name: "Cancel" });
152
+ expect(confirmButton).to.be.null;
153
+ expect(cancelButton).to.be.null;
154
+
155
+ // Clicking `Hide` again should show the modal again.
156
+ await user.click(hideButton);
157
+ confirmButton = getByRole("button", { name: "Suppress Availability" });
158
+
159
+ // Clicking the confirmation button should invoke the API and show a confirmation.
160
+ await user.click(confirmButton);
161
+ getByRole("heading", { level: 4, name: "Result" });
162
+ getByText(/Successfully suppressed book availability/);
163
+ getByRole("button", { name: "Dismiss" });
164
+
165
+ // Check that the API was invoked.
166
+ expect(suppressBook.mock.calls.length).to.equal(1);
167
+ expect(suppressBook.mock.calls[0][0]).to.equal("/suppress/href");
168
+ const fetchCalls = fetchMock.calls();
169
+ expect(fetchCalls.length).to.equal(1);
170
+ const fetchCall = fetchCalls[0];
171
+ const fetchOptions = fetchCalls[0][1];
172
+ expect(fetchCall[0]).to.equal("/suppress/href");
173
+ expect(fetchOptions["headers"]["X-CSRF-Token"]).to.contain("token");
174
+ expect(fetchOptions["method"]).to.equal("POST");
175
+ });
176
+ it("uses modal for unsuppress book confirmation", async () => {
177
+ // Configure standard constructors so that RTK Query works in tests with FetchMockJest
178
+ Object.assign(fetchMock.config, {
179
+ fetch,
180
+ Headers,
181
+ Request,
182
+ Response,
183
+ });
184
+
185
+ const user = userEvent.setup();
186
+
187
+ const { getByRole, getByText, queryByRole } = renderWithProviders(
188
+ <BookDetailsEditor
189
+ bookData={{ id: "id", title: "title", unsuppressPerLibraryLink }}
190
+ bookUrl="url"
191
+ csrfToken="token"
192
+ canSuppress={true}
193
+ {...dispatchProps}
194
+ />
195
+ );
196
+
197
+ // The `Restore` button should be present.
198
+ const restoreButton = getByRole("button", { name: "Restore" });
199
+
200
+ // Clicking `Restore` should show the book un/suppression modal.
201
+ await user.click(restoreButton);
202
+ getByRole("heading", { level: 4, name: "Restoring Availability" });
203
+ getByText(/to make this title visible in your library's catalog/);
204
+ let confirmButton = getByRole("button", { name: "Restore Availability" });
205
+ let cancelButton = getByRole("button", { name: "Cancel" });
206
+
207
+ // Clicking `Cancel` should close the modal.
208
+ await user.click(cancelButton);
209
+ confirmButton = queryByRole("button", { name: "Restore Availability" });
210
+ cancelButton = queryByRole("button", { name: "Cancel" });
211
+ expect(confirmButton).to.be.null;
212
+ expect(cancelButton).to.be.null;
213
+
214
+ // Clicking `Restore` again should show the modal again.
215
+ await user.click(restoreButton);
216
+ confirmButton = getByRole("button", { name: "Restore Availability" });
217
+
218
+ // Clicking the confirmation button should invoke the API and show a confirmation.
219
+ await user.click(confirmButton);
220
+ getByRole("heading", { level: 4, name: "Result" });
221
+ getByText(/Successfully unsuppressed book availability/);
222
+ getByRole("button", { name: "Dismiss" });
223
+
224
+ // Check that the API was invoked.
225
+ expect(unsuppressBook.mock.calls.length).to.equal(1);
226
+ expect(unsuppressBook.mock.calls[0][0]).to.equal("/unsuppress/href");
227
+ const fetchCalls = fetchMock.calls();
228
+ expect(fetchCalls.length).to.equal(1);
229
+ const fetchCall = fetchCalls[0];
230
+ const fetchOptions = fetchCalls[0][1];
231
+ expect(fetchCall[0]).to.equal("/unsuppress/href");
232
+ expect(fetchOptions["headers"]["X-CSRF-Token"]).to.contain("token");
233
+ expect(fetchOptions["method"]).to.equal("DELETE");
234
+ });
235
+ });
@@ -3,24 +3,245 @@ import { render } from "@testing-library/react";
3
3
  import LibraryStats, {
4
4
  CustomTooltip,
5
5
  } from "../../../src/components/LibraryStats";
6
- import { renderWithProviders } from "../testUtils/withProviders";
6
+ import {
7
+ componentWithProviders,
8
+ renderWithProviders,
9
+ } from "../testUtils/withProviders";
7
10
  import { ContextProviderProps } from "../../../src/components/ContextProvider";
8
11
 
9
12
  import {
10
13
  statisticsApiResponseData,
11
14
  testLibraryKey as sampleLibraryKey,
15
+ testLibraryName as sampleLibraryName,
12
16
  } from "../../__data__/statisticsApiResponseData";
13
- import { normalizeStatistics } from "../../../src/components/Stats";
17
+
18
+ import { normalizeStatistics } from "../../../src/features/stats/normalizeStatistics";
19
+ import { useGetStatsQuery } from "../../../src/features/stats/statsSlice";
20
+ import * as fetchMock from "fetch-mock-jest";
21
+ import { STATS_API_ENDPOINT } from "../../../src/features/stats/statsSlice";
22
+ import Stats from "../../../src/components/Stats";
23
+ import { renderHook } from "@testing-library/react-hooks";
24
+ import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces";
25
+ import { store } from "../../../src/store";
26
+ import { api } from "../../../src/features/api/apiSlice";
27
+
28
+ const normalizedData = normalizeStatistics(statisticsApiResponseData);
14
29
 
15
30
  describe("Dashboard Statistics", () => {
16
31
  // NB: This adds test to the already existing tests in:
17
- // - `src/components/__tests__/Stats-test.tsx`.
18
32
  // - `src/components/__tests__/LibraryStats-test.tsx`.
19
33
  // - `src/components/__tests__/SingleStatListItem-test.tsx`.
20
34
  //
21
35
  // Those tests should eventually be migrated here and
22
36
  // adapted to the Jest/React Testing Library paradigm.
23
37
 
38
+ // Configure standard constructors so that RTK Query works in tests with FetchMockJest
39
+ Object.assign(fetchMock.config, {
40
+ fetch,
41
+ Headers,
42
+ Request,
43
+ Response,
44
+ });
45
+
46
+ describe("query hook correctly handles fetch responses", () => {
47
+ const wrapper = componentWithProviders();
48
+
49
+ beforeEach(() => {
50
+ store.dispatch(api.util.resetApiState());
51
+ fetchMock.restore();
52
+ });
53
+ afterAll(() => {
54
+ store.dispatch(api.util.resetApiState());
55
+ fetchMock.restore();
56
+ });
57
+
58
+ it("returns data when fetch successful", async () => {
59
+ fetchMock.get(
60
+ `path:${STATS_API_ENDPOINT}`,
61
+ {
62
+ body: JSON.stringify(statisticsApiResponseData),
63
+ status: 200,
64
+ },
65
+ { overwriteRoutes: true }
66
+ );
67
+
68
+ const { result, waitFor } = renderHook(() => useGetStatsQuery(), {
69
+ wrapper,
70
+ });
71
+
72
+ // Expect loading status immediately after first use of the hook.
73
+ let { isSuccess, isError, error, data } = result.current;
74
+ expect(isSuccess).toBe(false);
75
+ expect(isError).toBe(false);
76
+ expect(error).toBe(undefined);
77
+ expect(data).toEqual(undefined);
78
+
79
+ // Once loaded, we should have our data.
80
+ await waitFor(() => !result.current.isLoading);
81
+ ({ isSuccess, isError, error, data } = result.current);
82
+
83
+ expect(isSuccess).toBe(true);
84
+ expect(isError).toBe(false);
85
+ expect(error).toBe(undefined);
86
+ expect(data).toEqual(normalizedData);
87
+
88
+ // But if we use the hook again, we should get the data back from
89
+ // the cache immediately, without loading state.
90
+ const { result: result2 } = renderHook(() => useGetStatsQuery(), {
91
+ wrapper,
92
+ });
93
+ ({ isSuccess, isError, error, data } = result2.current);
94
+
95
+ expect(isSuccess).toBe(true);
96
+ expect(isError).toBe(false);
97
+ expect(error).toBe(undefined);
98
+ expect(data).toEqual(normalizedData);
99
+ });
100
+
101
+ it("returns error and no data when request fails", async () => {
102
+ fetchMock.get(
103
+ `path:${STATS_API_ENDPOINT}`,
104
+ {
105
+ status: 500,
106
+ },
107
+ {
108
+ overwriteRoutes: true,
109
+ }
110
+ );
111
+
112
+ const { result, waitFor } = renderHook(() => useGetStatsQuery(), {
113
+ wrapper,
114
+ });
115
+
116
+ // Expect loading status immediately after first use of the hook.
117
+ let { isSuccess, isError, error, data } = result.current;
118
+ expect(isSuccess).toBe(false);
119
+ expect(isError).toBe(false);
120
+ expect(error).toBe(undefined);
121
+ expect(data).toEqual(undefined);
122
+
123
+ await waitFor(() => !result.current.isLoading);
124
+ ({ isSuccess, isError, error, data } = result.current);
125
+
126
+ expect(isSuccess).toBe(false);
127
+ expect(isError).toBe(true);
128
+ expect(data).toBe(undefined);
129
+ expect((error as FetchErrorData).status).toBe(500);
130
+
131
+ // But if we use the hook again, we should get our error back from
132
+ // the cache immediately, without loading state.
133
+ const { result: result2 } = renderHook(() => useGetStatsQuery(), {
134
+ wrapper,
135
+ });
136
+ ({ isSuccess, isError, error, data } = result2.current);
137
+
138
+ expect(isSuccess).toBe(false);
139
+ expect(isError).toBe(true);
140
+ expect(data).toBe(undefined);
141
+ expect((error as FetchErrorData).status).toBe(500);
142
+ });
143
+ });
144
+
145
+ describe("rendering", () => {
146
+ beforeAll(() => {
147
+ fetchMock.get(`path:${STATS_API_ENDPOINT}`, {
148
+ body: JSON.stringify(statisticsApiResponseData),
149
+ status: 200,
150
+ });
151
+ });
152
+ afterAll(() => {
153
+ fetchMock.restore();
154
+ });
155
+ afterEach(() => {
156
+ fetchMock.resetHistory();
157
+ });
158
+
159
+ const assertLoadingState = ({ getByRole }) => {
160
+ getByRole("dialog", { name: "Loading" });
161
+ getByRole("heading", { level: 1, name: "Loading" });
162
+ };
163
+ const assertNotLoadingState = ({ queryByRole }) => {
164
+ const missingLoadingDialog = queryByRole("dialog", { name: "Loading" });
165
+ const missingLoadingHeading = queryByRole("heading", {
166
+ level: 1,
167
+ name: "Loading",
168
+ });
169
+ expect(missingLoadingDialog).not.toBeInTheDocument();
170
+ expect(missingLoadingHeading).not.toBeInTheDocument();
171
+ };
172
+
173
+ it("shows/hides the loading indicator", async () => {
174
+ // We haven't tried to fetch anything yet.
175
+ expect(fetchMock.calls()).toHaveLength(0);
176
+
177
+ const { rerender, getByRole, queryByRole } = renderWithProviders(
178
+ <Stats />
179
+ );
180
+
181
+ // We should start in the loading state.
182
+ assertLoadingState({ getByRole });
183
+
184
+ // Wait a tick for the statistics to render.
185
+ await new Promise(process.nextTick);
186
+ // Now we've fetched something.
187
+ expect(fetchMock.calls()).toHaveLength(1);
188
+
189
+ rerender(<Stats />);
190
+
191
+ // We should show our content without the loading state.
192
+ assertNotLoadingState({ queryByRole });
193
+ getByRole("heading", { level: 2, name: "Statistics for All Libraries" });
194
+
195
+ // We haven't made another call, since the response is cached.
196
+ expect(fetchMock.calls()).toHaveLength(1);
197
+ });
198
+
199
+ it("doesn't fetch again, because response is cached", async () => {
200
+ const { getByRole, queryByRole } = renderWithProviders(<Stats />);
201
+
202
+ // We should show our content immediately, without entering the loading state.
203
+ assertNotLoadingState({ queryByRole });
204
+ getByRole("heading", { level: 2, name: "Statistics for All Libraries" });
205
+
206
+ // We never tried to fetch anything because the result is cached.
207
+ expect(fetchMock.calls()).toHaveLength(0);
208
+ });
209
+
210
+ it("show stats for a library, if a library is specified", async () => {
211
+ const { getByRole, queryByRole, getByText } = renderWithProviders(
212
+ <Stats library={sampleLibraryKey} />
213
+ );
214
+
215
+ // We should show our content immediately, without entering the loading state.
216
+ assertNotLoadingState({ queryByRole });
217
+ getByRole("heading", {
218
+ level: 2,
219
+ name: `${sampleLibraryName} Statistics`,
220
+ });
221
+ getByRole("heading", { level: 3, name: "Patrons" });
222
+ getByText("132");
223
+
224
+ // We never tried to fetch anything because the result is cached.
225
+ expect(fetchMock.calls()).toHaveLength(0);
226
+ });
227
+
228
+ it("shows site-wide stats when no library specified", async () => {
229
+ const { getByRole, getByText, queryByRole } = renderWithProviders(
230
+ <Stats />
231
+ );
232
+
233
+ // We should show our content immediately, without entering the loading state.
234
+ assertNotLoadingState({ queryByRole });
235
+
236
+ getByRole("heading", { level: 2, name: "Statistics for All Libraries" });
237
+ getByRole("heading", { level: 3, name: "Patrons" });
238
+ getByText("145");
239
+
240
+ // We never tried to fetch anything because the result is cached.
241
+ expect(fetchMock.calls()).toHaveLength(0);
242
+ });
243
+ });
244
+
24
245
  describe("requesting inventory reports", () => {
25
246
  // Convert from the API format to our in-app format.
26
247
  const statisticsData = normalizeStatistics(statisticsApiResponseData);
@@ -212,9 +433,7 @@ describe("Dashboard Statistics", () => {
212
433
  payload: defaultPayload,
213
434
  });
214
435
 
215
- const { container, getByRole } = render(
216
- <CustomTooltip {...tooltipProps} />
217
- );
436
+ const { container } = render(<CustomTooltip {...tooltipProps} />);
218
437
  const tooltipContent = container.querySelectorAll(".customTooltip");
219
438
 
220
439
  expect(tooltipContent).toHaveLength(0);