From 6accec31bed6fa7c1a2c6ed52d38bca9a78ef6f1 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Thu, 27 Jun 2024 20:37:10 -0400 Subject: [PATCH] Move book editor state to redux toolkit slices model. (PP-1350) (#119) --- package-lock.json | 57 +-- package.json | 6 +- src/__tests__/actions-test.ts | 94 ++--- src/actions.ts | 17 - src/api/submitForm.ts | 71 ++++ src/components/BookCoverEditor.tsx | 56 +-- src/components/BookDetails.tsx | 1 - src/components/BookDetailsContainer.tsx | 69 ++- src/components/BookDetailsEditor.tsx | 92 ++-- src/components/BookDetailsTabContainer.tsx | 47 ++- src/components/Classifications.tsx | 54 +-- src/components/ClassificationsForm.tsx | 3 +- .../__tests__/BookCoverEditor-test.tsx | 29 +- .../__tests__/BookDetailsContainer-test.tsx | 2 - .../__tests__/BookDetailsEditor-test.tsx | 20 +- .../BookDetailsTabContainer-test.tsx | 23 +- .../__tests__/Classifications-test.tsx | 2 + .../__tests__/ClassificationsForm-test.tsx | 2 +- src/features/book/bookEditorSlice.ts | 126 ++++++ src/reducers/__tests__/book-test.ts | 111 ----- src/reducers/book.ts | 62 --- src/reducers/index.ts | 3 - src/store.ts | 13 +- tests/jest/features/book.test.ts | 396 ++++++++++++++++++ 24 files changed, 826 insertions(+), 530 deletions(-) create mode 100644 src/api/submitForm.ts create mode 100644 src/features/book/bookEditorSlice.ts delete mode 100644 src/reducers/__tests__/book-test.ts delete mode 100644 src/reducers/book.ts create mode 100644 tests/jest/features/book.test.ts diff --git a/package-lock.json b/package-lock.json index 48ce0b904..e40b8f305 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,8 +33,8 @@ "react-redux": "^7.2.9", "react-router": "^3.2.0", "recharts": "^1.8.6", - "redux": "^4.0.1", - "redux-thunk": "^2.3.0", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", "request": "^2.85.0", "stream-browserify": "^3.0.0", "timers-browserify": "^2.0.12", @@ -72,7 +72,7 @@ "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-react": "^7.19.0", "eslint-plugin-react-hooks": "^4.0.0", - "fetch-mock": "^7.3.1", + "fetch-mock": "^10.0.7", "fetch-mock-jest": "^1.5.1", "fetch-ponyfill": "^7.1.0", "file-loader": "^6.2.0", @@ -3894,31 +3894,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/babel-polyfill": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz", - "integrity": "sha512-F2rZGQnAdaHWQ8YAoeRbukc7HS9QgdgeyJ0rQDd485v9opwuPvjpPFcOOT/WmkKTdgy9ESgSPXDcTNpzrGr6iQ==", - "dev": true, - "dependencies": { - "babel-runtime": "^6.26.0", - "core-js": "^2.5.0", - "regenerator-runtime": "^0.10.5" - } - }, - "node_modules/babel-polyfill/node_modules/core-js": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", - "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", - "dev": true, - "hasInstallScript": true - }, - "node_modules/babel-polyfill/node_modules/regenerator-runtime": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", - "integrity": "sha512-02YopEIhAgiBHWeoTiA8aitHDt8z6w+rQqNuIftlM+ZtvSl/brTouaU7DW6GO/cHtvxJvS4Hwv2ibKdxIRi24w==", - "dev": true - }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -7414,24 +7389,24 @@ } }, "node_modules/fetch-mock": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-7.7.3.tgz", - "integrity": "sha512-I4OkK90JFQnjH8/n3HDtWxH/I6D1wrxoAM2ri+nb444jpuH3RTcgvXx2el+G20KO873W727/66T7QhOvFxNHPg==", + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-10.0.7.tgz", + "integrity": "sha512-TFG42kMRJ6dZpUDeVTdXNjh5O4TchHU/UNk41a050TwKzRr5RJQbtckXDjXiQFHPKgXGUG5l2TY3ZZ2gokiXaQ==", "dev": true, - "hasInstallScript": true, "dependencies": { - "babel-polyfill": "^6.26.0", - "core-js": "^2.6.9", + "debug": "^4.1.1", "glob-to-regexp": "^0.4.0", + "is-subset": "^0.1.1", "lodash.isequal": "^4.5.0", "path-to-regexp": "^2.2.1", - "whatwg-url": "^6.5.0" + "querystring": "^0.2.1" }, "engines": { "node": ">=4.0.0" }, - "peerDependencies": { - "node-fetch": "*" + "funding": { + "type": "charity", + "url": "https://www.justgiving.com/refugee-support-europe" }, "peerDependenciesMeta": { "node-fetch": { @@ -7496,14 +7471,6 @@ } } }, - "node_modules/fetch-mock/node_modules/core-js": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", - "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", - "dev": true, - "hasInstallScript": true - }, "node_modules/fetch-ponyfill": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz", diff --git a/package.json b/package.json index 780b07f8d..2f92cba91 100644 --- a/package.json +++ b/package.json @@ -61,8 +61,8 @@ "react-redux": "^7.2.9", "react-router": "^3.2.0", "recharts": "^1.8.6", - "redux": "^4.0.1", - "redux-thunk": "^2.3.0", + "redux": "^4.2.1", + "redux-thunk": "^2.4.2", "request": "^2.85.0", "stream-browserify": "^3.0.0", "timers-browserify": "^2.0.12", @@ -100,7 +100,7 @@ "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-react": "^7.19.0", "eslint-plugin-react-hooks": "^4.0.0", - "fetch-mock": "^7.3.1", + "fetch-mock": "^10.0.7", "fetch-mock-jest": "^1.5.1", "fetch-ponyfill": "^7.1.0", "file-loader": "^6.2.0", diff --git a/src/__tests__/actions-test.ts b/src/__tests__/actions-test.ts index 9e0d5e2cb..6db166a8c 100644 --- a/src/__tests__/actions-test.ts +++ b/src/__tests__/actions-test.ts @@ -4,6 +4,7 @@ import { stub } from "sinon"; const fetchMock = require("fetch-mock"); import ActionCreator from "../actions"; +import { getBookData } from "../features/book/bookEditorSlice"; class MockDataFetcher { resolve: boolean = true; @@ -53,10 +54,11 @@ describe("actions", () => { it("dispatches request, success, and load", async () => { const dispatch = stub(); const responseText = "response"; - fetchMock.mock(url, responseText); - const fetchArgs = fetchMock.calls(); + fetchMock.post(url, responseText); await actions.postForm(type, url, formData)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${type}_${ActionCreator.REQUEST}` @@ -82,9 +84,10 @@ describe("actions", () => { // prettier-ignore const responseText = "{\"id\": \"test\", \"name\": \"test\"}"; fetchMock.mock(url, responseText); - const fetchArgs = fetchMock.calls(); await actions.postForm(type, url, formData, "POST", "", "JSON")(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${type}_${ActionCreator.REQUEST}` @@ -109,9 +112,10 @@ describe("actions", () => { const dispatch = stub(); const responseText = "response"; fetchMock.mock(url, responseText); - const fetchArgs = fetchMock.calls(); await actions.postForm(type, url, formData, "DELETE")(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(fetchMock.called()).to.equal(true); expect(fetchArgs[0][0]).to.equal(url); @@ -214,9 +218,10 @@ describe("actions", () => { it("dispatches request and success", async () => { const dispatch = stub(); fetchMock.mock(url, 200); - const fetchArgs = fetchMock.calls(); await actions.postJSON<{ test: number }>(type, url, jsonData)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(2); expect(dispatch.args[0][0].type).to.equal( `${type}_${ActionCreator.REQUEST}` @@ -288,55 +293,6 @@ describe("actions", () => { }); }); - describe("fetchBookAdmin", () => { - it("dispatches request, load, and success", async () => { - const dispatch = stub(); - const bookData = { - title: "test title", - }; - fetcher.testData = bookData; - fetcher.resolve = true; - - const data = await actions.fetchBookAdmin("http://example.com/book")( - dispatch - ); - expect(dispatch.callCount).to.equal(3); - expect(dispatch.args[0][0].type).to.equal( - ActionCreator.BOOK_ADMIN_REQUEST - ); - expect(dispatch.args[1][0].type).to.equal( - ActionCreator.BOOK_ADMIN_SUCCESS - ); - expect(dispatch.args[2][0].type).to.equal(ActionCreator.BOOK_ADMIN_LOAD); - expect(data).to.deep.equal(bookData); - }); - }); - - describe("editBook", () => { - it("dispatches request and success", async () => { - const editBookUrl = "http://example.com/editBook"; - const dispatch = stub(); - const formData = new (window as any).FormData(); - formData.append("title", "title"); - - fetchMock.mock(editBookUrl, "done"); - const fetchArgs = fetchMock.calls(); - - await actions.editBook(editBookUrl, formData)(dispatch); - expect(dispatch.callCount).to.equal(3); - expect(dispatch.args[0][0].type).to.equal( - ActionCreator.EDIT_BOOK_REQUEST - ); - expect(dispatch.args[1][0].type).to.equal( - ActionCreator.EDIT_BOOK_SUCCESS - ); - expect(fetchMock.called()).to.equal(true); - expect(fetchArgs[0][0]).to.equal(editBookUrl); - expect(fetchArgs[0][1].method).to.equal("POST"); - expect(fetchArgs[0][1].body).to.equal(formData); - }); - }); - describe("fetchComplaints", () => { it("dispatches request, load, and success", async () => { const dispatch = stub(); @@ -378,9 +334,10 @@ describe("actions", () => { }; fetchMock.mock(postComplaintUrl, 201); - const fetchArgs = fetchMock.calls(); await actions.postComplaint(postComplaintUrl, data)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(2); expect(dispatch.args[0][0].type).to.equal( ActionCreator.POST_COMPLAINT_REQUEST @@ -403,9 +360,10 @@ describe("actions", () => { formData.append("type", "test type"); fetchMock.mock(resolveComplaintsUrl, "server response"); - const fetchArgs = fetchMock.calls(); await actions.resolveComplaints(resolveComplaintsUrl, formData)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( ActionCreator.RESOLVE_COMPLAINTS_REQUEST @@ -458,12 +416,13 @@ describe("actions", () => { newGenreTree.forEach((genre) => formData.append("genres", genre)); fetchMock.mock(editClassificationsUrl, "server response"); - const fetchArgs = fetchMock.calls(); await actions.editClassifications( editClassificationsUrl, formData )(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( ActionCreator.EDIT_CLASSIFICATIONS_REQUEST @@ -631,9 +590,10 @@ describe("actions", () => { formData.append("name", "new name"); fetchMock.mock(editLibraryUrl, "server response"); - const fetchArgs = fetchMock.calls(); await actions.editLibrary(formData)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${ActionCreator.EDIT_LIBRARY}_${ActionCreator.REQUEST}` @@ -685,9 +645,10 @@ describe("actions", () => { formData.append("name", "new name"); fetchMock.mock(editCollectionUrl, "server response"); - const fetchArgs = fetchMock.calls(); await actions.editCollection(formData)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${ActionCreator.EDIT_COLLECTION}_${ActionCreator.REQUEST}` @@ -739,9 +700,10 @@ describe("actions", () => { formData.append("email", "email"); fetchMock.mock(editIndividualAdminUrl, "server response"); - const fetchArgs = fetchMock.calls(); await actions.editIndividualAdmin(formData)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${ActionCreator.EDIT_INDIVIDUAL_ADMIN}_${ActionCreator.REQUEST}` @@ -795,9 +757,10 @@ describe("actions", () => { const dispatch = stub(); fetchMock.mock(collectionSelfTestURL, "server response"); - const fetchArgs = fetchMock.calls(); await actions.runSelfTests(collectionSelfTestURL)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${ActionCreator.RUN_SELF_TESTS}_${ActionCreator.REQUEST}` @@ -820,9 +783,10 @@ describe("actions", () => { const response = "{\"id\": \"test\", \"name\": \"test\"}"; fetchMock.mock("/nypl/admin/manage_patrons", response); - const fetchArgs = fetchMock.calls(); const data = await actions.patronLookup(formData, "nypl")(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${ActionCreator.PATRON_LOOKUP}_${ActionCreator.REQUEST}` @@ -850,9 +814,10 @@ describe("actions", () => { "/nypl/admin/manage_patrons/reset_adobe_id", "server response" ); - const fetchArgs = fetchMock.calls(); const data = await actions.resetAdobeId(formData, "nypl")(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${ActionCreator.RESET_ADOBE_ID}_${ActionCreator.REQUEST}` @@ -967,9 +932,10 @@ describe("actions", () => { formData.append("announcements", "[]"); fetchMock.mock(editSitewideAnnouncementsUrl, "server response"); - const fetchArgs = fetchMock.calls(); await actions.editSitewideAnnouncements(formData)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( `${ActionCreator.EDIT_SITEWIDE_ANNOUNCEMENTS}_${ActionCreator.REQUEST}` diff --git a/src/actions.ts b/src/actions.ts index 81c332067..6cfd065b5 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,5 @@ import { AdvancedSearchQuery, - BookData, ComplaintsData, GenreTree, ClassificationData, @@ -131,14 +130,6 @@ export default class ActionCreator extends BaseActionCreator { static readonly RESET_LANES = "RESET_LANES"; static readonly CHANGE_LANE_ORDER = "CHANGE_LANE_ORDER"; - static readonly EDIT_BOOK_REQUEST = "EDIT_BOOK_REQUEST"; - static readonly EDIT_BOOK_SUCCESS = "EDIT_BOOK_SUCCESS"; - static readonly EDIT_BOOK_FAILURE = "EDIT_BOOK_FAILURE"; - static readonly BOOK_ADMIN_REQUEST = "BOOK_ADMIN_REQUEST"; - static readonly BOOK_ADMIN_SUCCESS = "BOOK_ADMIN_SUCCESS"; - static readonly BOOK_ADMIN_FAILURE = "BOOK_ADMIN_FAILURE"; - static readonly BOOK_ADMIN_LOAD = "BOOK_ADMIN_LOAD"; - static readonly COMPLAINTS_REQUEST = "COMPLAINTS_REQUEST"; static readonly COMPLAINTS_SUCCESS = "COMPLAINTS_SUCCESS"; static readonly COMPLAINTS_FAILURE = "COMPLAINTS_FAILURE"; @@ -329,14 +320,6 @@ export default class ActionCreator extends BaseActionCreator { }; } - fetchBookAdmin(url: string) { - return this.fetchOPDS(ActionCreator.BOOK_ADMIN, url).bind(this); - } - - editBook(url: string, data: FormData | null) { - return this.postForm(ActionCreator.EDIT_BOOK, url, data).bind(this); - } - fetchRoles() { const url = "/admin/roles"; return this.fetchJSON(ActionCreator.ROLES, url).bind(this); diff --git a/src/api/submitForm.ts b/src/api/submitForm.ts new file mode 100644 index 000000000..9931ffa1c --- /dev/null +++ b/src/api/submitForm.ts @@ -0,0 +1,71 @@ +import { + RequestError, + RequestRejector, +} from "@thepalaceproject/web-opds-client/lib/DataFetcher"; + +export const submitForm = ( + url: string, + { + data = null, + csrfToken = undefined, + returnType = undefined, + method = "POST", + defaultErrorMessage = "Failed to save changes", + } = {} +) => { + let err: RequestError; + return new Promise((resolve, reject: RequestRejector) => { + const headers = new Headers(); + if (csrfToken) { + headers.append("X-CSRF-Token", csrfToken); + } + fetch(url, { + method: method, + headers: headers, + body: data, + credentials: "same-origin", + }) + .then((response) => { + if (response.status === 200 || response.status === 201) { + if (response.json && returnType === "JSON") { + response.json().then((data) => { + resolve(response); + }); + } else if (response.text) { + response.text().then((text) => { + resolve(response); + }); + } else { + resolve(response); + } + } else { + response + .json() + .then((data) => { + err = { + status: response.status, + response: data.detail, + url: url, + }; + reject(err); + }) + .catch((parseError) => { + err = { + status: response.status, + response: defaultErrorMessage, + url: url, + }; + reject(err); + }); + } + }) + .catch((err) => { + err = { + status: null, + response: err.message, + url: url, + }; + reject(err); + }); + }); +}; diff --git a/src/components/BookCoverEditor.tsx b/src/components/BookCoverEditor.tsx index 888b0296d..30cbf9596 100644 --- a/src/components/BookCoverEditor.tsx +++ b/src/components/BookCoverEditor.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { Store } from "@reduxjs/toolkit"; -import { connect } from "react-redux"; +import { connect, ConnectedProps } from "react-redux"; import editorAdapter from "../editorAdapter"; import DataFetcher from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import ActionCreator from "../actions"; @@ -8,27 +8,10 @@ import ErrorMessage from "./ErrorMessage"; import EditableInput from "./EditableInput"; import { BookData, RightsStatusData } from "../interfaces"; import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; -import { RootState } from "../store"; +import { AppDispatch, RootState } from "../store"; import { Panel, Button, Form } from "library-simplified-reusable-components"; import UpdatingLoader from "./UpdatingLoader"; - -export interface BookCoverEditorStateProps { - bookAdminUrl?: string; - preview?: string; - rightsStatuses?: RightsStatusData; - fetchError?: FetchErrorData; - isFetching?: boolean; - previewFetchError?: FetchErrorData; - isFetchingPreview?: boolean; -} - -export interface BookCoverEditorDispatchProps { - fetchBook?: (url: string) => Promise; - fetchPreview?: (url: string, data: FormData) => Promise; - clearPreview?: () => Promise; - editCover?: (url: string, data: FormData) => Promise; - fetchRightsStatuses?: () => Promise; -} +import { getBookData } from "../features/book/bookEditorSlice"; export interface BookCoverEditorOwnProps { store?: Store; @@ -38,10 +21,9 @@ export interface BookCoverEditorOwnProps { refreshCatalog: () => Promise; } -export interface BookCoverEditorProps - extends BookCoverEditorStateProps, - BookCoverEditorDispatchProps, - BookCoverEditorOwnProps {} +const connector = connect(mapStateToProps, mapDispatchToProps); +export type BookCoverEditorProps = ConnectedProps & + BookCoverEditorOwnProps; /** Tab on the book details page for uploading a new book cover. */ export class BookCoverEditor extends React.Component { @@ -329,18 +311,18 @@ export class BookCoverEditor extends React.Component { } } -function mapStateToProps(state, ownProps) { +function mapStateToProps(state: RootState) { return { - bookAdminUrl: state.editor.book.url, + bookAdminUrl: state.bookEditor.url, preview: state.editor.bookCoverPreview.data, rightsStatuses: state.editor.rightsStatuses.data, isFetching: - state.editor.book.isFetching || + state.bookEditor.isFetching || state.editor.bookCover.isFetching || state.editor.rightsStatuses.isFetching || state.editor.bookCover.isEditing, fetchError: - state.editor.book.fetchError || + state.bookEditor.fetchError || state.editor.bookCover.fetchError || state.editor.rightsStatuses.fetchError, isFetchingPreview: state.editor.bookCoverPreview.isFetching, @@ -348,11 +330,14 @@ function mapStateToProps(state, ownProps) { }; } -function mapDispatchToProps(dispatch, ownProps) { +function mapDispatchToProps( + dispatch: AppDispatch, + ownProps: BookCoverEditorOwnProps +) { const fetcher = new DataFetcher({ adapter: editorAdapter }); const actions = new ActionCreator(fetcher, ownProps.csrfToken); return { - fetchBook: (url: string) => dispatch(actions.fetchBookAdmin(url)), + fetchBook: (url: string) => dispatch(getBookData({ url })), fetchPreview: (url: string, data: FormData) => dispatch(actions.fetchBookCoverPreview(url, data)), clearPreview: () => dispatch(actions.clearBookCoverPreview()), @@ -362,13 +347,4 @@ function mapDispatchToProps(dispatch, ownProps) { }; } -const ConnectedBookCoverEditor = connect< - BookCoverEditorStateProps, - BookCoverEditorDispatchProps, - BookCoverEditorOwnProps ->( - mapStateToProps, - mapDispatchToProps -)(BookCoverEditor); - -export default ConnectedBookCoverEditor; +export default connector(BookCoverEditor); diff --git a/src/components/BookDetails.tsx b/src/components/BookDetails.tsx index efaadbf96..c3c3e339b 100644 --- a/src/components/BookDetails.tsx +++ b/src/components/BookDetails.tsx @@ -1,4 +1,3 @@ -import * as React from "react"; import DefaultBookDetails, { BookDetailsProps as DefaultBookDetailsProps, } from "@thepalaceproject/web-opds-client/lib/components/BookDetails"; diff --git a/src/components/BookDetailsContainer.tsx b/src/components/BookDetailsContainer.tsx index dd7616b9a..243962525 100644 --- a/src/components/BookDetailsContainer.tsx +++ b/src/components/BookDetailsContainer.tsx @@ -14,42 +14,37 @@ export interface BookDetailsContainerContext { library: (collectionUrl: string, bookUrl: string) => string; } -/** Wrapper for `BookDetailsTabContainer` that extracts parameters from its context - and converts them into props. This component is passed into the OPDSCatalog from - web-opds-client to replace the body of the book details page. */ -export default class BookDetailsContainer extends React.Component< - BookDetailsContainerProps -> { - context: BookDetailsContainerContext; +const BookDetailsContainer = ( + props: BookDetailsContainerProps, + context: BookDetailsContainerContext +) => { + const child = React.Children.only(props.children) as React.ReactElement< + BookDetails + >; + const book = React.createElement(BookDetails, child.props); - static contextTypes = { - csrfToken: PropTypes.string.isRequired, - tab: PropTypes.string, - editorStore: PropTypes.object.isRequired, - library: PropTypes.func.isRequired, - }; + return ( +
+ + {book} + +
+ ); +}; +BookDetailsContainer.contextTypes = { + csrfToken: PropTypes.string.isRequired, + tab: PropTypes.string, + editorStore: PropTypes.object.isRequired, + library: PropTypes.func.isRequired, +}; - render(): JSX.Element { - const child = React.Children.only( - this.props.children - ) as React.ReactElement; - const book = React.createElement(BookDetails, child.props); - - return ( -
- - {book} - -
- ); - } -} +export default BookDetailsContainer; diff --git a/src/components/BookDetailsEditor.tsx b/src/components/BookDetailsEditor.tsx index 50d90291d..bd8813145 100644 --- a/src/components/BookDetailsEditor.tsx +++ b/src/components/BookDetailsEditor.tsx @@ -1,35 +1,15 @@ import * as React from "react"; -import { Store } from "@reduxjs/toolkit"; -import { connect } from "react-redux"; +import { AsyncThunkAction, Store } from "@reduxjs/toolkit"; +import { connect, ConnectedProps } from "react-redux"; import DataFetcher from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import ActionCreator from "../actions"; import editorAdapter from "../editorAdapter"; import BookEditForm from "./BookEditForm"; import ErrorMessage from "./ErrorMessage"; -import { BookData, RolesData, MediaData, LanguagesData } from "../interfaces"; -import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; -import { RootState } from "../store"; +import { AppDispatch, RootState } from "../store"; import { Button } from "library-simplified-reusable-components"; import UpdatingLoader from "./UpdatingLoader"; - -export interface BookDetailsEditorStateProps { - bookData?: BookData; - roles?: RolesData; - media?: MediaData; - languages?: LanguagesData; - bookAdminUrl?: string; - fetchError?: FetchErrorData; - editError?: FetchErrorData; - isFetching?: boolean; -} - -export interface BookDetailsEditorDispatchProps { - fetchBook: (url: string) => void; - fetchRoles: () => void; - fetchMedia: () => void; - fetchLanguages: () => void; - editBook: (url: string, data: FormData | null) => Promise; -} +import { getBookData, submitBookData } from "../features/book/bookEditorSlice"; export interface BookDetailsEditorOwnProps { bookUrl?: string; @@ -38,17 +18,15 @@ export interface BookDetailsEditorOwnProps { refreshCatalog?: () => Promise; } -export interface BookDetailsEditorProps - extends React.Props, - BookDetailsEditorStateProps, - BookDetailsEditorDispatchProps, - BookDetailsEditorOwnProps {} +const connector = connect(mapStateToProps, mapDispatchToProps); +export type BookDetailsEditorProps = ConnectedProps & + BookDetailsEditorOwnProps; /** Tab for editing a book's metadata on the book details page. */ export class BookDetailsEditor extends React.Component { constructor(props) { super(props); - this.editBook = this.editBook.bind(this); + this.postWithoutPayload = this.postWithoutPayload.bind(this); this.hide = this.hide.bind(this); this.restore = this.restore.bind(this); this.refreshMetadata = this.refreshMetadata.bind(this); @@ -58,7 +36,7 @@ export class BookDetailsEditor extends React.Component { UNSAFE_componentWillMount() { if (this.props.bookUrl) { const bookAdminUrl = this.props.bookUrl.replace("works", "admin/works"); - this.props.fetchBook(bookAdminUrl); + this.props.fetchBookData(bookAdminUrl); this.props.fetchRoles(); this.props.fetchMedia(); this.props.fetchLanguages(); @@ -68,7 +46,7 @@ export class BookDetailsEditor extends React.Component { UNSAFE_componentWillReceiveProps(nextProps) { if (nextProps.bookUrl && nextProps.bookUrl !== this.props.bookUrl) { const bookAdminUrl = nextProps.bookUrl.replace("works", "admin/works"); - this.props.fetchBook(bookAdminUrl); + this.props.fetchBookData(bookAdminUrl); } } @@ -122,7 +100,7 @@ export class BookDetailsEditor extends React.Component { media={this.props.media} languages={this.props.languages} disabled={this.props.isFetching} - editBook={this.props.editBook} + editBook={this.props.postBookData} refresh={this.refresh} /> )} @@ -136,67 +114,65 @@ export class BookDetailsEditor extends React.Component { } hide() { - return this.editBook(this.props.bookData.hideLink.href); + return this.postWithoutPayload(this.props.bookData.hideLink.href); } restore() { - return this.editBook(this.props.bookData.restoreLink.href); + return this.postWithoutPayload(this.props.bookData.restoreLink.href); } refreshMetadata() { - return this.editBook(this.props.bookData.refreshLink.href); + return this.postWithoutPayload(this.props.bookData.refreshLink.href); } refresh() { - this.props.fetchBook(this.props.bookAdminUrl); + this.props.fetchBookData(this.props.bookAdminUrl); this.props.refreshCatalog(); } - editBook(url) { - return this.props.editBook(url, null).then(this.refresh); + postWithoutPayload(url) { + return this.props.postBookData(url, null).then(this.refresh); } } -function mapStateToProps(state, ownProps) { +function mapStateToProps( + state: RootState, + ownProps: BookDetailsEditorOwnProps +) { return { - bookAdminUrl: state.editor.book.url, - bookData: state.editor.book.data || ownProps.bookData, + bookAdminUrl: state.bookEditor.url, + bookData: state.bookEditor.data, roles: state.editor.roles.data, media: state.editor.media.data, languages: state.editor.languages.data, isFetching: - state.editor.book.isFetching || + state.bookEditor.isFetching || state.editor.roles.isFetching || state.editor.media.isFetching || state.editor.languages.isFetching, fetchError: - state.editor.book.fetchError || + state.bookEditor.fetchError || state.editor.roles.fetchError || state.editor.media.fetchError || state.editor.languages.fetchError, - editError: state.editor.book.editError, + editError: state.bookEditor.editError, }; } -function mapDispatchToProps(dispatch, ownProps) { +function mapDispatchToProps( + dispatch: AppDispatch, + ownProps: BookDetailsEditorOwnProps +) { const fetcher = new DataFetcher({ adapter: editorAdapter }); const actions = new ActionCreator(fetcher, ownProps.csrfToken); return { - editBook: (url, data) => dispatch(actions.editBook(url, data)), - fetchBook: (url: string) => dispatch(actions.fetchBookAdmin(url)), + postBookData: (url: string, data) => + dispatch(submitBookData({ url, data, csrfToken: ownProps.csrfToken })), + fetchBookData: (url: string) => dispatch(getBookData({ url })), fetchRoles: () => dispatch(actions.fetchRoles()), fetchMedia: () => dispatch(actions.fetchMedia()), fetchLanguages: () => dispatch(actions.fetchLanguages()), }; } -const ConnectedBookDetailsEditor = connect< - BookDetailsEditorStateProps, - BookDetailsEditorDispatchProps, - BookDetailsEditorOwnProps ->( - mapStateToProps, - mapDispatchToProps -)(BookDetailsEditor); - -export default ConnectedBookDetailsEditor; +export default connector(BookDetailsEditor); diff --git a/src/components/BookDetailsTabContainer.tsx b/src/components/BookDetailsTabContainer.tsx index 999e18445..1c5cdd9c9 100644 --- a/src/components/BookDetailsTabContainer.tsx +++ b/src/components/BookDetailsTabContainer.tsx @@ -2,24 +2,41 @@ import * as React from "react"; import editorAdapter from "../editorAdapter"; import DataFetcher from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import ActionCreator from "../actions"; -import { connect } from "react-redux"; +import { connect, ConnectedProps } from "react-redux"; import BookDetailsEditor from "./BookDetailsEditor"; import Classifications from "./Classifications"; import BookCoverEditor from "./BookCoverEditor"; import CustomListsForBook from "./CustomListsForBook"; -import { BookData } from "../interfaces"; import { TabContainer, TabContainerProps } from "./TabContainer"; +import { RootState } from "../store"; -export interface BookDetailsTabContainerProps extends TabContainerProps { +interface BookDetailsTabContainerOwnProps extends TabContainerProps { bookUrl: string; - bookData?: BookData; collectionUrl: string; refreshCatalog: () => Promise; - complaintsCount?: number; - clearBook?: () => void; library: (collectionUrl, bookUrl) => string; + // extended from TabContainerProps in superclass + // store?: Store; + // csrfToken?: string; + // tab: string; + // class?: string; + // from store + // bookData?: BookData; + // complaintsCount?: number; + // dispatch actions + // clearBook?: () => void; } +const connectOptions = { pure: true }; +const connector = connect( + mapStateToProps, + mapDispatchToProps, + null, + connectOptions +); +export type BookDetailsTabContainerProps = ConnectedProps & + BookDetailsTabContainerOwnProps; + /** Wraps the book details component from OPDSWebClient with additional tabs for editing metadata, classifications, and complaints. */ export class BookDetailsTabContainer extends TabContainer< @@ -95,7 +112,7 @@ export class BookDetailsTabContainer extends TabContainer< return "details"; } - tabDisplayName(name) { + tabDisplayName(name: string) { let capitalized = name.charAt(0).toUpperCase() + name.slice(1); if ( name === "complaints" && @@ -107,8 +124,8 @@ export class BookDetailsTabContainer extends TabContainer< } } -function mapStateToProps(state, ownProps) { - let complaintsCount; +function mapStateToProps(state: RootState) { + let complaintsCount: number | undefined; if (state.editor.complaints.data) { complaintsCount = Object.keys(state.editor.complaints.data).reduce( @@ -123,7 +140,7 @@ function mapStateToProps(state, ownProps) { return { complaintsCount: complaintsCount, - bookData: state.editor.book.data, + bookData: state.bookEditor.data, }; } @@ -135,12 +152,4 @@ function mapDispatchToProps(dispatch) { }; } -const connectOptions = { pure: true }; -const ConnectedBookDetailsTabContainer = connect( - mapStateToProps, - mapDispatchToProps, - null, - connectOptions -)(BookDetailsTabContainer); - -export default ConnectedBookDetailsTabContainer; +export default connector(BookDetailsTabContainer); diff --git a/src/components/Classifications.tsx b/src/components/Classifications.tsx index bf7ed5498..c14306d9c 100644 --- a/src/components/Classifications.tsx +++ b/src/components/Classifications.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { Store } from "@reduxjs/toolkit"; -import { connect } from "react-redux"; +import { connect, ConnectedProps } from "react-redux"; import editorAdapter from "../editorAdapter"; import DataFetcher from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import ActionCreator from "../actions"; @@ -8,26 +8,9 @@ import ErrorMessage from "./ErrorMessage"; import ClassificationsForm from "./ClassificationsForm"; import ClassificationsTable from "./ClassificationsTable"; import { BookData, GenreTree, ClassificationData } from "../interfaces"; -import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; -import { RootState } from "../store"; +import { AppDispatch, RootState } from "../store"; import UpdatingLoader from "./UpdatingLoader"; - -export interface ClassificationsStateProps { - // from store - bookAdminUrl?: string; - genreTree?: GenreTree; - classifications?: ClassificationData[]; - fetchError?: FetchErrorData; - isFetching?: boolean; -} - -export interface ClassificationsDispatchProps { - // from actions - fetchBook?: (url: string) => Promise; - fetchGenreTree?: (url: string) => Promise; - fetchClassifications?: (url: string) => Promise; - editClassifications?: (url: string, data: FormData) => Promise; -} +import { getBookData } from "../features/book/bookEditorSlice"; export interface ClassificationsOwnProps { // from parent @@ -38,10 +21,9 @@ export interface ClassificationsOwnProps { refreshCatalog: () => Promise; } -export interface ClassificationsProps - extends ClassificationsStateProps, - ClassificationsDispatchProps, - ClassificationsOwnProps {} +const connector = connect(mapStateToProps, mapDispatchToProps); +export type ClassificationsProps = ConnectedProps & + ClassificationsOwnProps; /** Tab on the book details page with a table of a book's current classifications and a form for editing them. */ @@ -120,25 +102,28 @@ export class Classifications extends React.Component { } } -function mapStateToProps(state, ownProps) { +function mapStateToProps(state: RootState, ownProps: ClassificationsOwnProps) { return { - bookAdminUrl: state.editor.book.url, + bookAdminUrl: state.bookEditor.url, genreTree: state.editor.classifications.genreTree, classifications: state.editor.classifications.classifications, isFetching: state.editor.classifications.isFetchingGenreTree || state.editor.classifications.isEditingClassifications || state.editor.classifications.isFetchingClassifications || - state.editor.book.isFetching, + state.bookEditor.isFetching, fetchError: state.editor.classifications.fetchError, }; } -function mapDispatchToProps(dispatch, ownProps) { +function mapDispatchToProps( + dispatch: AppDispatch, + ownProps: ClassificationsOwnProps +) { const fetcher = new DataFetcher({ adapter: editorAdapter }); const actions = new ActionCreator(fetcher, ownProps.csrfToken); return { - fetchBook: (url: string) => dispatch(actions.fetchBookAdmin(url)), + fetchBook: (url: string) => dispatch(getBookData({ url })), fetchGenreTree: (url: string) => dispatch(actions.fetchGenreTree(url)), fetchClassifications: (url: string) => dispatch(actions.fetchClassifications(url)), @@ -147,13 +132,4 @@ function mapDispatchToProps(dispatch, ownProps) { }; } -const ConnectedClassifications = connect< - ClassificationsStateProps, - ClassificationsDispatchProps, - ClassificationsOwnProps ->( - mapStateToProps, - mapDispatchToProps -)(Classifications); - -export default ConnectedClassifications; +export default connector(Classifications); diff --git a/src/components/ClassificationsForm.tsx b/src/components/ClassificationsForm.tsx index 7e3f17d23..0809b6455 100644 --- a/src/components/ClassificationsForm.tsx +++ b/src/components/ClassificationsForm.tsx @@ -236,7 +236,8 @@ export default class ClassificationsForm extends React.Component< newBook.targetAgeRange[0] !== this.props.book.targetAgeRange[0] || newBook.targetAgeRange[1] !== this.props.book.targetAgeRange[1] || newBook.fiction !== this.props.book.fiction || - newBook.categories.sort() !== this.props.book.categories.sort() + newBook.categories.slice().sort().join("::") !== + this.props.book.categories.slice().sort().join("::") ); } diff --git a/src/components/__tests__/BookCoverEditor-test.tsx b/src/components/__tests__/BookCoverEditor-test.tsx index 8f250d5a7..501739f60 100644 --- a/src/components/__tests__/BookCoverEditor-test.tsx +++ b/src/components/__tests__/BookCoverEditor-test.tsx @@ -48,12 +48,22 @@ describe("BookCoverEditor", () => { beforeEach(() => { wrapper = mount( ); }); @@ -213,17 +223,22 @@ describe("BookCoverEditor", () => { refreshCatalog = stub(); wrapper = mount( ); }); diff --git a/src/components/__tests__/BookDetailsContainer-test.tsx b/src/components/__tests__/BookDetailsContainer-test.tsx index db9044fee..f0a59caa7 100644 --- a/src/components/__tests__/BookDetailsContainer-test.tsx +++ b/src/components/__tests__/BookDetailsContainer-test.tsx @@ -30,7 +30,6 @@ describe("BookDetailsContainer", () => { store = buildStore(); context = { editorStore: store, - tab: "tab", csrfToken: "token", library: stub(), }; @@ -60,7 +59,6 @@ describe("BookDetailsContainer", () => { expect(tabContainer).to.be.ok; expect(tabContainer.props().bookUrl).to.equal("book url"); expect(tabContainer.props().collectionUrl).to.equal("collection url"); - expect(tabContainer.props().tab).to.equal("tab"); expect(tabContainer.props().library).to.equal(context.library); expect(tabContainer.props().csrfToken).to.equal("token"); expect(tabContainer.props().refreshCatalog).to.equal(refreshCatalog); diff --git a/src/components/__tests__/BookDetailsEditor-test.tsx b/src/components/__tests__/BookDetailsEditor-test.tsx index be142495c..7a2e920e1 100644 --- a/src/components/__tests__/BookDetailsEditor-test.tsx +++ b/src/components/__tests__/BookDetailsEditor-test.tsx @@ -10,25 +10,25 @@ import BookEditForm from "../BookEditForm"; import ErrorMessage from "../ErrorMessage"; describe("BookDetailsEditor", () => { - let fetchBook; + let fetchBookData; let fetchRoles; let fetchMedia; let fetchLanguages; - let editBook; + let postBookData; let dispatchProps; beforeEach(() => { - fetchBook = stub(); + fetchBookData = stub(); fetchRoles = stub(); fetchMedia = stub(); fetchLanguages = stub(); - editBook = stub(); + postBookData = stub(); dispatchProps = { - fetchBook, + fetchBookData, fetchRoles, fetchMedia, fetchLanguages, - editBook, + postBookData, }; }); @@ -42,8 +42,8 @@ describe("BookDetailsEditor", () => { /> ); - expect(fetchBook.callCount).to.equal(1); - expect(fetchBook.args[0][0]).to.equal("admin/works/1234"); + expect(fetchBookData.callCount).to.equal(1); + expect(fetchBookData.args[0][0]).to.equal("admin/works/1234"); expect(fetchRoles.callCount).to.equal(1); expect(fetchMedia.callCount).to.equal(1); expect(fetchLanguages.callCount).to.equal(1); @@ -63,8 +63,8 @@ describe("BookDetailsEditor", () => { wrapper.setProps({ bookUrl: newPermalink }); wrapper.update(); - expect(fetchBook.callCount).to.equal(2); - expect(fetchBook.args[1][0]).to.equal("admin/works/5555"); + expect(fetchBookData.callCount).to.equal(2); + expect(fetchBookData.args[1][0]).to.equal("admin/works/5555"); }); it("shows title", () => { diff --git a/src/components/__tests__/BookDetailsTabContainer-test.tsx b/src/components/__tests__/BookDetailsTabContainer-test.tsx index 3c1685354..63d418360 100644 --- a/src/components/__tests__/BookDetailsTabContainer-test.tsx +++ b/src/components/__tests__/BookDetailsTabContainer-test.tsx @@ -18,19 +18,28 @@ describe("BookDetailsTabContainer", () => { let push; let store; + const TEST_BOOK_URL = "book url"; + const TEST_COLLECTION_URL = "collection url"; + beforeEach(() => { store = buildStore(); push = stub(); context = mockRouterContext(push); wrapper = mount( "library"} + // from store + complaintsCount={0} + bookData={null} + // dispatch actions + clearBook={stub()} + // required by TabContainer + tab={null} >
Moby Dick
, @@ -72,17 +81,17 @@ describe("BookDetailsTabContainer", () => { it("shows editor", () => { const editor = wrapper.find(BookDetailsEditor); expect(editor.props().csrfToken).to.equal("token"); - expect(editor.props().bookUrl).to.equal("book url"); + expect(editor.props().bookUrl).to.equal(TEST_BOOK_URL); }); it("shows classifications", () => { const classifications = wrapper.find(Classifications); - expect(classifications.props().bookUrl).to.equal("book url"); + expect(classifications.props().bookUrl).to.equal(TEST_BOOK_URL); }); it("shows lists", () => { const lists = wrapper.find(CustomListsForBook); - expect(lists.props().bookUrl).to.equal("book url"); + expect(lists.props().bookUrl).to.equal(TEST_BOOK_URL); expect(lists.props().library).to.equal("library"); }); @@ -92,7 +101,7 @@ describe("BookDetailsTabContainer", () => { const label = tabs.at(1).text(); expect(push.callCount).to.equal(1); expect(push.args[0][0]).to.equal( - context.pathFor("collection url", "book url", label) + context.pathFor(TEST_COLLECTION_URL, TEST_BOOK_URL, label) ); }); diff --git a/src/components/__tests__/Classifications-test.tsx b/src/components/__tests__/Classifications-test.tsx index ac5d25219..78c9df670 100644 --- a/src/components/__tests__/Classifications-test.tsx +++ b/src/components/__tests__/Classifications-test.tsx @@ -45,6 +45,8 @@ describe("Classifications", () => { bookAdminUrl="book admin url" genreTree={genreData} classifications={classificationsData} + isFetching={false} + fetchError={null} refreshCatalog={refreshCatalog} fetchGenreTree={fetchGenreTree} fetchClassifications={fetchClassifications} diff --git a/src/components/__tests__/ClassificationsForm-test.tsx b/src/components/__tests__/ClassificationsForm-test.tsx index 1dec3689e..56f17766e 100644 --- a/src/components/__tests__/ClassificationsForm-test.tsx +++ b/src/components/__tests__/ClassificationsForm-test.tsx @@ -402,7 +402,7 @@ describe("ClassificationsForm", () => { expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); }); - it("doesn't update state upoen receiving new state-unrelated props", () => { + it("doesn't update state upon receiving new state-unrelated props", () => { // state updated with new form inputs wrapper.setState({ fiction: false, genres: ["Cooking"] }); // form submitted, disabling form diff --git a/src/features/book/bookEditorSlice.ts b/src/features/book/bookEditorSlice.ts new file mode 100644 index 000000000..d8848e5c1 --- /dev/null +++ b/src/features/book/bookEditorSlice.ts @@ -0,0 +1,126 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { BookData } from "../../interfaces"; +import DataFetcher, { + RequestError, +} from "@thepalaceproject/web-opds-client/lib/DataFetcher"; +import editorAdapter from "../../editorAdapter"; +import { submitForm } from "../../api/submitForm"; +import { RootState } from "../../store"; +import ActionCreator from "../../actions"; + +export interface BookState { + url: string; + data: BookData; + isFetching: boolean; + fetchError: RequestError; + editError: RequestError; +} + +export const initialState: BookState = { + url: null, + data: null, + isFetching: false, + fetchError: null, + editError: null, +}; + +interface InitialState { + url: string; + data: BookData; + isFetching: boolean; + fetchError: RequestError; +} + +const bookEditorSlice = createSlice({ + name: "bookEditor", + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(ActionCreator.BOOK_CLEAR, (state, action) => { + // Handle resetting the book data via actions from the web-opds-client. + return initialState; + }) + .addCase(getBookData.pending, (state, action) => { + const { url } = action.meta.arg; + state.url = url; + state.data = null; + state.isFetching = true; + state.fetchError = null; + }) + .addCase(getBookData.fulfilled, (state, action) => { + const { url } = action.meta.arg; + state.url = url; + state.data = action.payload as BookData; + state.isFetching = false; + state.fetchError = null; + }) + .addCase(getBookData.rejected, (state, action) => { + const { url } = action.meta.arg; + state.url = url; + state.data = null; + state.isFetching = false; + state.fetchError = action.payload as RequestError; + }) + .addCase(submitBookData.pending, (state, action) => { + state.isFetching = true; + state.editError = null; + }) + .addCase(submitBookData.fulfilled, (state, action) => { + state.isFetching = false; + state.editError = null; + }) + .addCase(submitBookData.rejected, (state, action) => { + state.isFetching = false; + state.editError = action.payload as RequestError; + }); + }, +}); + +export type GetBookDataArgs = { + url: string; +}; + +export const getBookData = createAsyncThunk( + bookEditorSlice.reducerPath + "/getBookData", + async ({ url }: GetBookDataArgs, thunkAPI) => { + const fetcher = new DataFetcher({ adapter: editorAdapter }); + try { + const result = await fetcher.fetchOPDSData(url); + return result; + } catch (e) { + return thunkAPI.rejectWithValue(e); + } + } +); + +export const submitBookData = createAsyncThunk( + bookEditorSlice.reducerPath + "/submitBookData", + async ( + { + url, + data, + csrfToken = undefined, + }: { url: string; data: FormData; csrfToken?: string }, + thunkAPI + ) => { + try { + const result = await submitForm(url, { data, csrfToken }); + // If we've successfully submitted the form, we need to re-fetch the book data. + const { + bookEditor: { url: bookAdminUrl }, + } = thunkAPI.getState() as RootState; + const reFetchBookData = getBookData({ url: bookAdminUrl }); + thunkAPI.dispatch(reFetchBookData); + // And finally, we return our result for fulfillment. + return result; + } catch (e) { + return thunkAPI.rejectWithValue(e); + } + } +); + +export const bookEditorActions = { + ...bookEditorSlice.actions, +}; +export default bookEditorSlice.reducer; diff --git a/src/reducers/__tests__/book-test.ts b/src/reducers/__tests__/book-test.ts deleted file mode 100644 index 8a0c6e8a8..000000000 --- a/src/reducers/__tests__/book-test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { expect } from "chai"; - -import book from "../book"; -import ActionCreator from "../../actions"; - -describe("book reducer", () => { - const initState = { - url: null, - data: null, - isFetching: false, - fetchError: null, - editError: null, - }; - - const fetchedState = { - url: "test url", - data: { - id: "id", - title: "test book", - }, - isFetching: false, - fetchError: null, - editError: null, - }; - - it("returns initial state for unrecognized action", () => { - expect(book(undefined, {})).to.deep.equal(initState); - }); - - it("handles CLEAR_BOOK", () => { - const action = { type: ActionCreator.BOOK_CLEAR }; - expect(book(fetchedState, action)).to.deep.equal(initState); - }); - - it("handles BOOK_ADMIN_REQUEST", () => { - const action = { type: ActionCreator.BOOK_ADMIN_REQUEST, url: "test url" }; - const oldState = Object.assign({}, initState, { editError: "error" }); - const newState = Object.assign({}, initState, { - url: "test url", - isFetching: true, - editError: null, - }); - expect(book(initState, action)).to.deep.equal(newState); - }); - - it("handles EDIT_BOOK_REQUEST", () => { - const action = { type: ActionCreator.EDIT_BOOK_REQUEST }; - const newState = Object.assign({}, fetchedState, { - isFetching: true, - }); - expect(book(fetchedState, action)).to.deep.equal(newState); - }); - - it("handles BOOK_ADMIN_FAILURE", () => { - const action = { - type: ActionCreator.BOOK_ADMIN_FAILURE, - error: "test error", - }; - const oldState = { - url: "test url", - data: null, - isFetching: true, - fetchError: null, - editError: null, - }; - const newState = Object.assign({}, oldState, { - fetchError: "test error", - isFetching: false, - }); - expect(book(oldState, action)).to.deep.equal(newState); - }); - - it("handles EDIT_BOOK_FAILURE", () => { - const action = { - type: ActionCreator.EDIT_BOOK_FAILURE, - error: "test error", - }; - const oldState = { - url: "test url", - data: null, - isFetching: true, - fetchError: null, - editError: null, - }; - const newState = Object.assign({}, oldState, { - editError: "test error", - isFetching: false, - }); - expect(book(oldState, action)).to.deep.equal(newState); - }); - - it("handles BOOK_ADMIN_LOAD", () => { - const action = { - type: ActionCreator.BOOK_ADMIN_LOAD, - data: "test data", - url: "test url", - }; - const oldState = { - url: "test url", - data: null, - isFetching: true, - fetchError: null, - editError: null, - }; - const newState = Object.assign({}, oldState, { - data: "test data", - isFetching: false, - }); - expect(book(oldState, action)).to.deep.equal(newState); - }); -}); diff --git a/src/reducers/book.ts b/src/reducers/book.ts deleted file mode 100644 index df41acc6c..000000000 --- a/src/reducers/book.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { BookData } from "../interfaces"; -import { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; -import ActionCreator from "../actions"; - -export interface BookState { - url: string; - data: BookData; - isFetching: boolean; - fetchError: RequestError; - editError: RequestError; -} - -const initialState: BookState = { - url: null, - data: null, - isFetching: false, - fetchError: null, - editError: null, -}; - -export default (state: BookState = initialState, action) => { - switch (action.type) { - case ActionCreator.BOOK_CLEAR: - return initialState; - - case ActionCreator.BOOK_ADMIN_REQUEST: - return Object.assign({}, state, { - url: action.url, - isFetching: true, - fetchError: null, - editError: null, - }); - - case ActionCreator.EDIT_BOOK_REQUEST: - return Object.assign({}, state, { - isFetching: true, - editError: null, - }); - - case ActionCreator.BOOK_ADMIN_LOAD: - return Object.assign({}, state, { - url: action.url, - data: action.data, - isFetching: false, - }); - - case ActionCreator.BOOK_ADMIN_FAILURE: - return Object.assign({}, state, { - fetchError: action.error, - isFetching: false, - }); - - case ActionCreator.EDIT_BOOK_FAILURE: - return Object.assign({}, state, { - editError: action.error, - isFetching: false, - }); - - default: - return state; - } -}; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 4ebf606f9..e766a191a 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,5 +1,4 @@ import { combineReducers } from "redux"; -import book, { BookState } from "./book"; import complaints, { ComplaintsState } from "./complaints"; import classifications, { ClassificationsState } from "./classifications"; import bookCoverPreview, { BookCoverPreviewState } from "./bookCoverPreview"; @@ -62,7 +61,6 @@ import { } from "../interfaces"; export interface State { - book: BookState; complaints: ComplaintsState; classifications: ClassificationsState; bookCoverPreview: BookCoverPreviewState; @@ -101,7 +99,6 @@ export interface State { } export default combineReducers({ - book, complaints, classifications, bookCoverPreview, diff --git a/src/store.ts b/src/store.ts index 870069b61..b276ea6f7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,22 +1,29 @@ -import { configureStore, Store } from "@reduxjs/toolkit"; +import { configureStore } from "@reduxjs/toolkit"; import catalogReducers from "@thepalaceproject/web-opds-client/lib/reducers/index"; import { State as CatalogState } from "@thepalaceproject/web-opds-client/lib/state"; +import bookEditorSlice from "./features/book/bookEditorSlice"; import editorReducers, { State as EditorState } from "./reducers/index"; -export interface RootState { +export interface CombinedState { editor: EditorState; catalog: CatalogState; } /** Build a redux store with reducers specific to the admin interface as well as reducers from web-opds-client. */ -export default function buildStore(initialState?: RootState): Store { +export default function buildStore(initialState?: CombinedState) { return configureStore({ reducer: { editor: editorReducers, catalog: catalogReducers, + bookEditor: bookEditorSlice, }, preloadedState: initialState, + devTools: process.env.NODE_ENV !== "production", }); } + +export const store = buildStore(); +export type AppDispatch = typeof store.dispatch; +export type RootState = ReturnType; diff --git a/tests/jest/features/book.test.ts b/tests/jest/features/book.test.ts new file mode 100644 index 000000000..a3249d9d1 --- /dev/null +++ b/tests/jest/features/book.test.ts @@ -0,0 +1,396 @@ +import reducer, { + initialState, + getBookData, + GetBookDataArgs, + submitBookData, +} from "../../../src/features/book/bookEditorSlice"; +import { expect } from "chai"; +import * as fetchMock from "fetch-mock-jest"; +import { store } from "../../../src/store"; +import { BookData } from "@thepalaceproject/web-opds-client/lib/interfaces"; +import { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; +import { AsyncThunkAction, Dispatch } from "@reduxjs/toolkit"; +import ActionCreator from "@thepalaceproject/web-opds-client/lib/actions"; + +const SAMPLE_BOOK_ADMIN_DETAIL = ` + + Tea-Cup Reading and Fortune-Telling by Tea Leaves + by a Highland Seer + First written in the year 1881 by A Highland Seer, it contains a list of omens, both good and bad, many of which are very familiar. Folklore, mystic paths, and enlightenment of the divine are laid out in simple terms to help the everyday person interpret patterns found in tea leaves. (Google Books) + 60c23c76-f789-a51d-cd0b-31d2decf1271 + en + GEORGE SULLY AND COMPANY + 1921-01-01 + urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1 + + 2022-12-17T00:00:00Z + 2023-05-03T16:31:10+00:00 + + + Homer + + + + + + + + + + + + + + + +`; +const SAMPLE_BOOK_DATA_BROKEN_XML = `BROKEN ${SAMPLE_BOOK_ADMIN_DETAIL}`; +const FETCH_OPDS_PARSE_ERROR_MESSAGE = "Failed to parse OPDS data"; + +describe("Redux bookEditorSlice...", () => { + const bookData = { id: "urn:something:something", title: "test title" }; + + const fetchedState = { + url: "test url", + data: { ...bookData }, + isFetching: false, + fetchError: null, + editError: null, + }; + + describe("reducers...", () => { + it("should return the initial state from undefined, if no action is passed", () => { + expect(reducer(undefined, { type: "unknown" })).to.deep.equal( + initialState + ); + }); + it("should return the initial state from initialState, if no action is passed", () => { + expect(reducer(initialState, { type: "unknown" })).to.deep.equal( + initialState + ); + }); + it("should handle BOOK_CLEAR", () => { + // This is dispatched by `web-opds`client`, but we need to handle it, too. + const action = { type: ActionCreator.BOOK_CLEAR }; + + expect(reducer(fetchedState, action)).to.deep.equal(initialState); + }); + + it("should handle getBookData.pending", () => { + const action = { + type: getBookData.pending.type, + meta: { arg: { url: "https://example.com/book" } }, + }; + const previousState = { ...initialState, url: null, isFetching: false }; + const state = reducer(previousState, action); + + expect(state.url).to.equal("https://example.com/book"); + expect(state.data).to.be.null; + expect(state.isFetching).to.equal(true); + expect(state.fetchError).to.be.null; + expect(state.editError).to.be.null; + }); + it("should handle getBookData.fulfilled", () => { + const action = { + type: getBookData.fulfilled.type, + meta: { arg: { url: "https://example.com/book" } }, + payload: bookData, + }; + const previousState = { + ...initialState, + url: null, + data: null, + isFetching: true, + }; + const state = reducer(previousState, action); + + expect(state.url).to.equal("https://example.com/book"); + expect(state.data).to.deep.equal(bookData); + expect(state.isFetching).to.equal(false); + expect(state.fetchError).to.be.null; + expect(state.editError).to.be.null; + }); + it("should handle getBookData.rejected", () => { + const errorObject = { error: "some error object" }; + const action = { + type: getBookData.rejected.type, + meta: { arg: { url: "https://example.com/book" } }, + payload: errorObject, + }; + const previousState = { + ...initialState, + url: null, + data: null, + isFetching: true, + }; + const state = reducer(previousState, action); + + expect(state.url).to.equal("https://example.com/book"); + expect(state.data).to.be.null; + expect(state.isFetching).to.equal(false); + expect(state.fetchError).to.deep.equal(errorObject); + expect(state.editError).to.be.null; + }); + + it("should handle submitBookData.pending", () => { + const action = { type: submitBookData.pending.type }; + const previousState = { ...fetchedState, isFetching: false }; + const state = reducer(previousState, action); + + expect(state).to.deep.equal({ ...fetchedState, isFetching: true }); + }); + it("should handle submitBookData.fulfilled", () => { + const action = { + type: submitBookData.fulfilled.type, + payload: "some value", + }; + const previousState = { ...fetchedState, isFetching: true }; + const state = reducer(previousState, action); + + expect(state).to.deep.equal({ + ...fetchedState, + isFetching: false, + editError: null, + }); + }); + it("should handle submitBookData.rejected", () => { + const action = { + type: submitBookData.rejected.type, + payload: "some value", + }; + const previousState = { ...fetchedState, isFetching: true }; + const state = reducer(previousState, action); + + expect(state).to.deep.equal({ + ...fetchedState, + isFetching: false, + editError: "some value", + }); + }); + }); + + describe("thunks...", () => { + describe("getBookData...", () => { + const goodBookUrl = "https://example.com/book"; + const brokenBookUrl = "https://example.com/broken-book"; + const errorBookUrl = "https://example.com/error-book"; + + const dispatch = jest.fn(); + const getState = jest.fn().mockReturnValue({ + bookEditor: initialState, + }); + + beforeAll(() => { + fetchMock + .get(goodBookUrl, { body: SAMPLE_BOOK_ADMIN_DETAIL, status: 200 }) + .get(brokenBookUrl, { + body: SAMPLE_BOOK_DATA_BROKEN_XML, + status: 200, + }) + .get(errorBookUrl, { body: "Internal server error", status: 400 }); + }); + + afterEach(() => { + fetchMock.resetHistory(); + dispatch.mockClear(); + }); + afterAll(() => fetchMock.restore()); + + it("should return the book data on the happy path", async () => { + const action = getBookData({ url: goodBookUrl }); + + const result = await action(dispatch, getState, undefined); + const dispatchCalls = dispatch.mock.calls; + + const payload = result.payload as BookData; + expect(payload.id).to.equal( + "urn:uuid:1cca9468-c447-4303-bc5a-c57470b85cb1" + ); + expect(payload.title).to.equal( + "Tea-Cup Reading and Fortune-Telling by Tea Leaves" + ); + + expect(dispatchCalls.length).to.equal(2); + expect(dispatchCalls[0][0].type).to.equal(getBookData.pending.type); + expect(dispatchCalls[0][0].payload).to.equal(undefined); + expect(dispatchCalls[0][0].meta.arg).to.deep.equal({ + url: goodBookUrl, + }); + expect(dispatchCalls[1][0].type).to.equal(getBookData.fulfilled.type); + expect(dispatchCalls[1][0].payload).to.deep.equal(payload); + expect(dispatchCalls[1][0].meta.arg).to.deep.equal({ + url: goodBookUrl, + }); + }); + it("should return an error, if the data is malformed", async () => { + const action = getBookData({ url: brokenBookUrl }); + + const result = await action(dispatch, getState, undefined); + const dispatchCalls = dispatch.mock.calls; + + const payload = result.payload as RequestError; + expect(payload.response).to.equal(FETCH_OPDS_PARSE_ERROR_MESSAGE); + expect(payload.url).to.equal(brokenBookUrl); + + expect(dispatchCalls.length).to.equal(2); + expect(dispatchCalls[0][0].type).to.equal(getBookData.pending.type); + expect(dispatchCalls[0][0].payload).to.equal(undefined); + expect(dispatchCalls[0][0].meta.arg).to.deep.equal({ + url: brokenBookUrl, + }); + expect(dispatchCalls[1][0].type).to.equal(getBookData.rejected.type); + expect(dispatchCalls[1][0].payload).to.deep.equal(payload); + expect(dispatchCalls[1][0].meta.arg).to.deep.equal({ + url: brokenBookUrl, + }); + }); + it("should return an error, if the HTTP request fails", async () => { + const action = getBookData({ url: errorBookUrl }); + + const result = await action(dispatch, getState, undefined); + const dispatchCalls = dispatch.mock.calls; + + const payload = result.payload as RequestError; + + expect(result.type).to.equal(getBookData.rejected.type); + expect(result.meta.arg).to.deep.equal({ url: errorBookUrl }); + expect(payload.response).to.equal("Internal server error"); + expect(payload.url).to.equal(errorBookUrl); + + expect(dispatchCalls.length).to.equal(2); + expect(dispatchCalls[0][0].type).to.equal(getBookData.pending.type); + expect(dispatchCalls[0][0].payload).to.equal(undefined); + expect(dispatchCalls[0][0].meta.arg).to.deep.equal({ + url: errorBookUrl, + }); + expect(dispatchCalls[1][0].type).to.equal(getBookData.rejected.type); + expect(dispatchCalls[1][0].payload).to.deep.equal(payload); + expect(dispatchCalls[1][0].meta.arg).to.deep.equal({ + url: errorBookUrl, + }); + }); + }); + + describe("submitBookData...", () => { + const goodBookUrl = "https://example.com/book"; + const editBookUrl = `${goodBookUrl}/edit`; + const brokenBookUrl = "https://example.com/broken-book"; + const errorBookUrl = "https://example.com/error-book"; + const csrfTokenHeader = "X-CSRF-Token"; + const validCsrfToken = "valid-csrf-token"; + + const badCsrfTokenResponseBody = { + type: "http://librarysimplified.org/terms/problem/invalid-csrf-token", + title: "Invalid CSRF token", + status: 400, + detail: "There was an error saving your changes.", + }; + + const dispatch = jest.fn(); + const getState = jest.fn().mockReturnValue({ + bookEditor: initialState, + }); + + beforeAll(() => { + fetchMock + .post( + { + name: "valid-csrf-token-post", + url: editBookUrl, + headers: { [csrfTokenHeader]: validCsrfToken }, + }, + { body: "Success!", status: 201 } + ) + .post( + { name: "invalid-csrf-token-post", url: editBookUrl }, + { body: badCsrfTokenResponseBody, status: 400 } + ) + .get(goodBookUrl, { body: SAMPLE_BOOK_ADMIN_DETAIL, status: 200 }); + }); + + afterEach(() => { + fetchMock.resetHistory(); + dispatch.mockClear(); + }); + + afterEach(fetchMock.resetHistory); + afterAll(() => fetchMock.restore()); + + it("should post the book data on the happy path", async () => { + const csrfToken = validCsrfToken; + const formData = new FormData(); + formData.append("id", "urn:something:something"); + formData.append("title", "title"); + + const action = submitBookData({ + url: editBookUrl, + data: formData, + csrfToken, + }); + + const result = await action(dispatch, getState, undefined); + const dispatchCalls = dispatch.mock.calls; + const fetchCalls = fetchMock.calls(); + + expect(fetchCalls.length).to.equal(1); + expect(fetchCalls[0].identifier).to.equal("valid-csrf-token-post"); + expect( + (fetchCalls[0][1].headers as Headers).get(csrfTokenHeader) + ).to.equal(validCsrfToken); + + expect(fetchCalls[0][0]).to.equal(editBookUrl); + expect(fetchCalls[0][1].method).to.equal("POST"); + expect(fetchCalls[0][1].body).to.equal(formData); + + expect(dispatchCalls.length).to.equal(3); + expect(dispatchCalls[0][0].type).to.equal(submitBookData.pending.type); + expect(dispatchCalls[0][0].payload).to.equal(undefined); + // On a successful update, the second dispatch is to re-fetch the updated book data. + // The third dispatch is for the fulfilled action. + expect(dispatchCalls[2][0].type).to.equal( + submitBookData.fulfilled.type + ); + expect(dispatchCalls[2][0].payload.body.toString()).to.equal( + "Success!" + ); + }); + it("should fail, if the user is unauthorized", async () => { + const csrfToken = "invalid-token"; + const formData = new FormData(); + formData.append("id", "urn:something:something"); + formData.append("title", "title"); + + const action = submitBookData({ + url: editBookUrl, + data: formData, + csrfToken, + }); + + const result = await action(dispatch, getState, undefined); + const dispatchCalls = dispatch.mock.calls; + const fetchCalls = fetchMock.calls(); + + expect(fetchCalls.length).to.equal(1); + expect(fetchCalls[0].identifier).to.equal("invalid-csrf-token-post"); + expect( + (fetchCalls[0][1].headers as Headers).get(csrfTokenHeader) + ).not.to.equal(validCsrfToken); + + expect(fetchCalls[0][0]).to.equal(editBookUrl); + expect(fetchCalls[0][1].method).to.equal("POST"); + expect(fetchCalls[0][1].body).to.equal(formData); + + expect(dispatchCalls.length).to.equal(2); + expect(dispatchCalls[0][0].type).to.equal(submitBookData.pending.type); + expect(dispatchCalls[0][0].payload).to.equal(undefined); + // There is no re-fetch on a failed request, ... + // ...so the second dispatch is for the rejected action. + expect(dispatchCalls[1][0].type).to.equal(submitBookData.rejected.type); + expect(dispatchCalls[1][0].payload.status).to.equal(400); + expect(dispatchCalls[1][0].payload.response).to.equal( + "There was an error saving your changes." + ); + }); + }); + }); +});