From 1d107106415c852a69de860336667491939d5f86 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Thu, 13 Jun 2024 16:00:27 -0400 Subject: [PATCH 01/18] WIP - 2024-06-13 --- src/features/apiSlice.ts | 10 ++++++ src/features/book/bookSlice.ts | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/features/apiSlice.ts create mode 100644 src/features/book/bookSlice.ts diff --git a/src/features/apiSlice.ts b/src/features/apiSlice.ts new file mode 100644 index 000000000..24695683f --- /dev/null +++ b/src/features/apiSlice.ts @@ -0,0 +1,10 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +// export const api = createApi({ +// baseQuery: fetchBaseQuery({ +// baseUrl: "" +// }), +// endpoints: (builder) => ({ +// +// }, +// }); diff --git a/src/features/book/bookSlice.ts b/src/features/book/bookSlice.ts new file mode 100644 index 000000000..553b745c9 --- /dev/null +++ b/src/features/book/bookSlice.ts @@ -0,0 +1,65 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { BookData } from "../../interfaces"; +import { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; + +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, +}; + +const bookSlice = createSlice({ + name: "book", + initialState, + reducers: { + bookCleared(state) { + state = initialState; + }, + bookAdminRequested(state, action) { + const { url } = action.payload; + state = { + ...state, + url, + isFetching: true, + fetchError: null, + editError: null, + }; + }, + bookAdminLoaded(state, action) { + const { url, data } = action.payload; + state = { ...state, url, data, isFetching: false }; + }, + bookAdminFailed(state, action) { + const { url, error } = action.payload; + state = { ...state, url, isFetching: false, fetchError: error }; + }, + editBookRequested(state, action) { + state = { ...state, isFetching: true, editError: null }; + }, + editBookFailed(state, action) { + const { error } = action.payload; + state = { ...state, isFetching: false, editError: error }; + }, + }, +}); + +export const { + bookCleared, + bookAdminRequested, + bookAdminLoaded, + bookAdminFailed, + editBookRequested, + editBookFailed, +} = bookSlice.actions; + +export default bookSlice.reducer; From 0c64a5e2cf2fc81ecebeafd08911a8598e65cff8 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Thu, 13 Jun 2024 17:47:50 -0400 Subject: [PATCH 02/18] Upgrade some packages. --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48ce0b904..4685ac277 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", diff --git a/package.json b/package.json index 780b07f8d..be97dce0b 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", From 1b305f004bb5269a9ae53f01799322fc0c637af7 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Thu, 13 Jun 2024 17:54:18 -0400 Subject: [PATCH 03/18] buildStore take `csrfToken`. --- src/components/ContextProvider.tsx | 6 +++++- src/index.tsx | 4 +++- src/store.ts | 29 +++++++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/components/ContextProvider.tsx b/src/components/ContextProvider.tsx index e5487d406..789aedd22 100644 --- a/src/components/ContextProvider.tsx +++ b/src/components/ContextProvider.tsx @@ -31,7 +31,11 @@ export default class ContextProvider extends React.Component< constructor(props) { super(props); - this.store = props.store ?? buildStore(); + this.store = + props.store ?? + buildStore({ + csrfToken: props.csrfToken, + }); this.admin = new Admin(props.roles || [], props.email || null); this.pathFor = (collectionUrl: string, bookUrl: string, tab?: string) => { let path = "/admin/web"; diff --git a/src/index.tsx b/src/index.tsx index 25fa86bf3..e570ba8a1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -72,7 +72,9 @@ class CirculationAdmin { const queryClient = new QueryClient(); - const store = buildStore(); + const store = buildStore({ + csrfToken: config.csrfToken, + }); const appElement = "opds-catalog"; const app = config.settingUp ? ( diff --git a/src/store.ts b/src/store.ts index 870069b61..112743c48 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,22 +1,47 @@ import { configureStore, Store } from "@reduxjs/toolkit"; +import { apiSlice } from "./features/admin/admin-api-slice"; import catalogReducers from "@thepalaceproject/web-opds-client/lib/reducers/index"; import { State as CatalogState } from "@thepalaceproject/web-opds-client/lib/state"; import editorReducers, { State as EditorState } from "./reducers/index"; -export interface RootState { +export interface CombinedState { editor: EditorState; catalog: CatalogState; } +type BuildStoreArgs = { + initialState?: CombinedState; + csrfToken?: string; +}; + +export type ThunkExtraArgument = { + csrfToken: string; +}; + /** 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, + csrfToken, +}: BuildStoreArgs = {}): Store { + console.log("buildStore", initialState, csrfToken); return configureStore({ reducer: { editor: editorReducers, catalog: catalogReducers, + [apiSlice.reducerPath]: apiSlice.reducer, }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + thunk: { + extraArgument: { csrfToken: "...csrfToken-text-here..." }, + }, + }).concat(apiSlice.middleware), preloadedState: initialState, }); } + +export const store = buildStore(); +export type AppDispatch = typeof store.dispatch; +export type RootState = ReturnType; From 0e526e8419ca682a19b34642c263790d5959c057 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Sat, 15 Jun 2024 11:55:06 -0400 Subject: [PATCH 04/18] Convert `BookDetailsContainer` to functional component. --- src/components/BookDetailsContainer.tsx | 69 ++++++++++++------------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/src/components/BookDetailsContainer.tsx b/src/components/BookDetailsContainer.tsx index dd7616b9a..8f53af9ce 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; From 62c23c8a85a069e9d166b1fdb37b306d479d2378 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Sat, 15 Jun 2024 16:47:33 -0400 Subject: [PATCH 05/18] Clean up `BookDetailsTabContainer` class/HOC. --- src/components/BookDetailsContainer.tsx | 4 +- src/components/BookDetailsTabContainer.tsx | 45 ++++++++++++------- .../__tests__/BookDetailsContainer-test.tsx | 2 - .../BookDetailsTabContainer-test.tsx | 23 +++++++--- 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/src/components/BookDetailsContainer.tsx b/src/components/BookDetailsContainer.tsx index 8f53af9ce..49d10de68 100644 --- a/src/components/BookDetailsContainer.tsx +++ b/src/components/BookDetailsContainer.tsx @@ -26,14 +26,12 @@ const BookDetailsContainer = ( return (
{book} diff --git a/src/components/BookDetailsTabContainer.tsx b/src/components/BookDetailsTabContainer.tsx index 999e18445..f1c77f938 100644 --- a/src/components/BookDetailsTabContainer.tsx +++ b/src/components/BookDetailsTabContainer.tsx @@ -2,30 +2,49 @@ 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"; +import { Store } from "@reduxjs/toolkit"; -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; + csrfToken: string; + store: Store; + // 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< BookDetailsTabContainerProps > { componentWillUnmount() { + console.log( + "\n\nUnmounting BookDetailsTabContainer... and clearing book data\n\n" + ); this.props.clearBook(); } @@ -95,7 +114,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 +126,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( @@ -135,12 +154,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/__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__/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) ); }); From 35481176e3551313820793d5e8ae9b2c946cbcc6 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Tue, 18 Jun 2024 10:37:48 -0400 Subject: [PATCH 06/18] Add back some tab container props. --- src/components/BookDetailsContainer.tsx | 2 ++ src/components/BookDetailsTabContainer.tsx | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/BookDetailsContainer.tsx b/src/components/BookDetailsContainer.tsx index 49d10de68..243962525 100644 --- a/src/components/BookDetailsContainer.tsx +++ b/src/components/BookDetailsContainer.tsx @@ -26,6 +26,8 @@ const BookDetailsContainer = ( return (
Promise; library: (collectionUrl, bookUrl) => string; - csrfToken: string; - store: Store; + // extended from TabContainerProps in superclass + // store?: Store; + // csrfToken?: string; + // tab: string; + // class?: string; // from store // bookData?: BookData; // complaintsCount?: number; From fdf4b0a0a9208fbb1d5fe38c11cef92a9374586b Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Mon, 17 Jun 2024 09:20:30 -0400 Subject: [PATCH 07/18] Clean up `BookDetailsEditor` class/HOC. --- src/components/BookDetailsEditor.tsx | 30 ++++++++++------------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/components/BookDetailsEditor.tsx b/src/components/BookDetailsEditor.tsx index 50d90291d..05ed7e555 100644 --- a/src/components/BookDetailsEditor.tsx +++ b/src/components/BookDetailsEditor.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 DataFetcher from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import ActionCreator from "../actions"; import editorAdapter from "../editorAdapter"; @@ -38,11 +38,9 @@ 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 { @@ -157,10 +155,13 @@ export class BookDetailsEditor extends React.Component { } } -function mapStateToProps(state, ownProps) { +function mapStateToProps( + state: RootState, + ownProps: BookDetailsEditorOwnProps +) { return { bookAdminUrl: state.editor.book.url, - bookData: state.editor.book.data || ownProps.bookData, + bookData: state.editor.book.data, roles: state.editor.roles.data, media: state.editor.media.data, languages: state.editor.languages.data, @@ -178,7 +179,7 @@ function mapStateToProps(state, ownProps) { }; } -function mapDispatchToProps(dispatch, ownProps) { +function mapDispatchToProps(dispatch, ownProps: BookDetailsEditorOwnProps) { const fetcher = new DataFetcher({ adapter: editorAdapter }); const actions = new ActionCreator(fetcher, ownProps.csrfToken); return { @@ -190,13 +191,4 @@ function mapDispatchToProps(dispatch, ownProps) { }; } -const ConnectedBookDetailsEditor = connect< - BookDetailsEditorStateProps, - BookDetailsEditorDispatchProps, - BookDetailsEditorOwnProps ->( - mapStateToProps, - mapDispatchToProps -)(BookDetailsEditor); - -export default ConnectedBookDetailsEditor; +export default connector(BookDetailsEditor); From 7dce377631039e269bba944376db9f01af24797e Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Mon, 17 Jun 2024 13:10:58 -0400 Subject: [PATCH 08/18] Clean up `BookDetails`. --- src/components/BookDetails.tsx | 1 - 1 file changed, 1 deletion(-) 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"; From 61e334e6bc941441fd6bc0d1572bd0a6ac1d813e Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Fri, 21 Jun 2024 12:15:52 -0400 Subject: [PATCH 09/18] WIP - bookEditorSlice working. --- src/actions.ts | 14 ++--- src/components/BookDetailsEditor.tsx | 56 +++++++++---------- src/features/book/bookEditorSlice.ts | 81 ++++++++++++++++++++++++++++ src/reducers/book.ts | 42 +++++++-------- src/store.ts | 13 +++-- 5 files changed, 145 insertions(+), 61 deletions(-) create mode 100644 src/features/book/bookEditorSlice.ts diff --git a/src/actions.ts b/src/actions.ts index 81c332067..1697f7a7f 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -134,10 +134,10 @@ export default class ActionCreator extends BaseActionCreator { 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 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"; @@ -329,9 +329,9 @@ export default class ActionCreator extends BaseActionCreator { }; } - fetchBookAdmin(url: string) { - return this.fetchOPDS(ActionCreator.BOOK_ADMIN, url).bind(this); - } + // 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); diff --git a/src/components/BookDetailsEditor.tsx b/src/components/BookDetailsEditor.tsx index 05ed7e555..91b5b9386 100644 --- a/src/components/BookDetailsEditor.tsx +++ b/src/components/BookDetailsEditor.tsx @@ -1,35 +1,35 @@ import * as React from "react"; -import { Store } from "@reduxjs/toolkit"; +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 { 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 } from "../features/book/bookEditorSlice"; +import { AsyncThunkConfig } from "@reduxjs/toolkit/dist/createAsyncThunk"; + +// 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; +// } export interface BookDetailsEditorOwnProps { bookUrl?: string; @@ -157,21 +157,21 @@ export class BookDetailsEditor extends React.Component { function mapStateToProps( state: RootState, - ownProps: BookDetailsEditorOwnProps + ownProps: BookDetailsEditorOwnProps, ) { return { - bookAdminUrl: state.editor.book.url, - bookData: state.editor.book.data, + 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, @@ -184,7 +184,7 @@ function mapDispatchToProps(dispatch, ownProps: BookDetailsEditorOwnProps) { const actions = new ActionCreator(fetcher, ownProps.csrfToken); return { editBook: (url, data) => dispatch(actions.editBook(url, data)), - fetchBook: (url: string) => dispatch(actions.fetchBookAdmin(url)), + fetchBook: (url: string) => dispatch(getBookData(url)), // dispatch(actions.fetchBookAdmin(url)), fetchRoles: () => dispatch(actions.fetchRoles()), fetchMedia: () => dispatch(actions.fetchMedia()), fetchLanguages: () => dispatch(actions.fetchLanguages()), diff --git a/src/features/book/bookEditorSlice.ts b/src/features/book/bookEditorSlice.ts new file mode 100644 index 000000000..21378e4ab --- /dev/null +++ b/src/features/book/bookEditorSlice.ts @@ -0,0 +1,81 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { BookData } from "../../interfaces"; +import DataFetcher, { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; +import editorAdapter from "../../editorAdapter"; + +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, +}; + +interface InitialState { + url: string; + data: BookData; + isFetching: boolean; + fetchError: RequestError; +} + +const bookEditorSlice = createSlice({ + name: "bookEditor", + initialState, + reducers: { + bookCleared(state, action) { + state = initialState; + } + }, + extraReducers: (builder) => { + builder + .addCase(getBookData.pending, (state, action) => { + console.log("getBookData.pending", { action, state }); + state.url = action.meta.arg; + state.data = null; + state.isFetching = true; + state.fetchError = null; + }) + .addCase(getBookData.fulfilled, (state, action) => { + console.log("getBookData.fulfilled", { action, state }); + // @ts-ignore + state.data = action.payload; + state.isFetching = false; + state.fetchError = null; + }) + .addCase(getBookData.rejected, (state, action) => { + console.log("getBookData.rejected", { action, state }); + state.data = null; + state.isFetching = false; + state.fetchError = action.payload as RequestError; + }) + .addMatcher((action) => true, (state, action) => { + console.log("Unhandled action", action.type, {action, state}); + }) + }, +}); + +export const getBookData = createAsyncThunk( + bookEditorSlice.reducerPath + "/getBookData", + async (url: string, thunkAPI) => { + console.log("getBookData thunkAPI", thunkAPI); + const fetcher = new DataFetcher({ adapter: editorAdapter }); + try { + const result = await fetcher.fetchOPDSData(url); + console.log(bookEditorSlice.reducerPath + "/getBookData()", {url, result}); + return result; + } catch (e) { + console.log(bookEditorSlice.reducerPath + "/getBookData()", {url, e}); + return thunkAPI.rejectWithValue(e); + } +}); + +// export const { } = bookEditorSlice.actions; +export default bookEditorSlice.reducer; diff --git a/src/reducers/book.ts b/src/reducers/book.ts index df41acc6c..381dcaac6 100644 --- a/src/reducers/book.ts +++ b/src/reducers/book.ts @@ -20,36 +20,36 @@ const initialState: BookState = { export default (state: BookState = initialState, action) => { switch (action.type) { + // case ActionCreator.BOOK_ADMIN_REQUEST: + // return Object.assign({}, state, { + // url: action.url, + // isFetching: true, + // fetchError: null, + // 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.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, diff --git a/src/store.ts b/src/store.ts index 112743c48..1c004d2d2 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,8 +1,9 @@ import { configureStore, Store } from "@reduxjs/toolkit"; -import { apiSlice } from "./features/admin/admin-api-slice"; +// import { apiSlice } from "./features/admin/admin-api-slice"; 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 CombinedState { @@ -24,21 +25,23 @@ export type ThunkExtraArgument = { export default function buildStore({ initialState, csrfToken, -}: BuildStoreArgs = {}): Store { - console.log("buildStore", initialState, csrfToken); +}: BuildStoreArgs = {}) { + // console.log("buildStore", initialState, csrfToken); return configureStore({ reducer: { editor: editorReducers, catalog: catalogReducers, - [apiSlice.reducerPath]: apiSlice.reducer, + bookEditor: bookEditorSlice, + // [apiSlice.reducerPath]: apiSlice.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ thunk: { extraArgument: { csrfToken: "...csrfToken-text-here..." }, }, - }).concat(apiSlice.middleware), + }), // .concat(apiSlice.middleware), preloadedState: initialState, + devTools: process.env.NODE_ENV !== "production", }); } From d82797e0c0baa88fb5b1ce29da2c508aa1ba14df Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Sat, 22 Jun 2024 12:48:16 -0400 Subject: [PATCH 10/18] WIP - bookEditorSlice working / tests passing. --- src/__tests__/actions-test.ts | 25 +-- src/components/BookCoverEditor.tsx | 56 ++---- src/components/BookDetailsEditor.tsx | 11 +- src/components/Classifications.tsx | 54 ++---- .../__tests__/BookCoverEditor-test.tsx | 29 ++- .../__tests__/Classifications-test.tsx | 2 + src/features/book/bookEditorSlice.ts | 58 +++--- src/reducers/__tests__/book-test.ts | 97 +++++----- tests/jest/features/book.test.ts | 171 ++++++++++++++++++ 9 files changed, 319 insertions(+), 184 deletions(-) create mode 100644 tests/jest/features/book.test.ts diff --git a/src/__tests__/actions-test.ts b/src/__tests__/actions-test.ts index 9e0d5e2cb..6f9f22aec 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; @@ -288,30 +289,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"; 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/BookDetailsEditor.tsx b/src/components/BookDetailsEditor.tsx index 91b5b9386..0eb43ca7e 100644 --- a/src/components/BookDetailsEditor.tsx +++ b/src/components/BookDetailsEditor.tsx @@ -6,7 +6,7 @@ import ActionCreator from "../actions"; import editorAdapter from "../editorAdapter"; import BookEditForm from "./BookEditForm"; import ErrorMessage from "./ErrorMessage"; -import { RootState } from "../store"; +import { AppDispatch, RootState } from "../store"; import { Button } from "library-simplified-reusable-components"; import UpdatingLoader from "./UpdatingLoader"; import { getBookData } from "../features/book/bookEditorSlice"; @@ -157,7 +157,7 @@ export class BookDetailsEditor extends React.Component { function mapStateToProps( state: RootState, - ownProps: BookDetailsEditorOwnProps, + ownProps: BookDetailsEditorOwnProps ) { return { bookAdminUrl: state.bookEditor.url, @@ -179,12 +179,15 @@ function mapStateToProps( }; } -function mapDispatchToProps(dispatch, ownProps: BookDetailsEditorOwnProps) { +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(getBookData(url)), // dispatch(actions.fetchBookAdmin(url)), + fetchBook: (url: string) => dispatch(getBookData({ url })), // dispatch(actions.fetchBookAdmin(url)), fetchRoles: () => dispatch(actions.fetchRoles()), fetchMedia: () => dispatch(actions.fetchMedia()), fetchLanguages: () => dispatch(actions.fetchLanguages()), 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/__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__/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/features/book/bookEditorSlice.ts b/src/features/book/bookEditorSlice.ts index 21378e4ab..8ea6b5963 100644 --- a/src/features/book/bookEditorSlice.ts +++ b/src/features/book/bookEditorSlice.ts @@ -1,6 +1,8 @@ import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import { BookData } from "../../interfaces"; -import DataFetcher, { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; +import DataFetcher, { + RequestError, +} from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import editorAdapter from "../../editorAdapter"; export interface BookState { @@ -11,7 +13,7 @@ export interface BookState { editError: RequestError; } -const initialState: BookState = { +export const initialState: BookState = { url: null, data: null, isFetching: false, @@ -32,50 +34,60 @@ const bookEditorSlice = createSlice({ reducers: { bookCleared(state, action) { state = initialState; - } + }, }, extraReducers: (builder) => { builder .addCase(getBookData.pending, (state, action) => { - console.log("getBookData.pending", { action, state }); - state.url = action.meta.arg; + // console.log("getBookData.pending", { action, state }); + const { url } = action.meta.arg; + state.url = url; state.data = null; state.isFetching = true; state.fetchError = null; }) .addCase(getBookData.fulfilled, (state, action) => { - console.log("getBookData.fulfilled", { action, state }); - // @ts-ignore - state.data = action.payload; + // console.log("getBookData.fulfilled", { action, state }); + 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) => { - console.log("getBookData.rejected", { action, state }); + // console.log("getBookData.rejected", { action, state }); + const { url } = action.meta.arg; + state.url = url; state.data = null; state.isFetching = false; state.fetchError = action.payload as RequestError; }) - .addMatcher((action) => true, (state, action) => { - console.log("Unhandled action", action.type, {action, state}); - }) + .addMatcher( + (action) => true, + (state, action) => { + // console.log("Unhandled action", action.type, {action, state}); + } + ); }, }); export const getBookData = createAsyncThunk( bookEditorSlice.reducerPath + "/getBookData", - async (url: string, thunkAPI) => { - console.log("getBookData thunkAPI", thunkAPI); - const fetcher = new DataFetcher({ adapter: editorAdapter }); - try { - const result = await fetcher.fetchOPDSData(url); - console.log(bookEditorSlice.reducerPath + "/getBookData()", {url, result}); - return result; - } catch (e) { - console.log(bookEditorSlice.reducerPath + "/getBookData()", {url, e}); + async ({ url }: { url: string }, thunkAPI) => { + // console.log("getBookData thunkAPI", thunkAPI); + const fetcher = new DataFetcher({ adapter: editorAdapter }); + try { + const result = await fetcher.fetchOPDSData(url); + // console.log(bookEditorSlice.reducerPath + "/getBookData()", {url, result}); + return result; + } catch (e) { + // console.log(bookEditorSlice.reducerPath + "/getBookData()", {url, e}); return thunkAPI.rejectWithValue(e); + } } -}); +); -// export const { } = bookEditorSlice.actions; +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 index 8a0c6e8a8..a116828f3 100644 --- a/src/reducers/__tests__/book-test.ts +++ b/src/reducers/__tests__/book-test.ts @@ -2,6 +2,9 @@ import { expect } from "chai"; import book from "../book"; import ActionCreator from "../../actions"; +import bookEditorSlice, { + getBookData, +} from "../../features/book/bookEditorSlice"; describe("book reducer", () => { const initState = { @@ -32,16 +35,16 @@ describe("book reducer", () => { 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 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 }; @@ -51,24 +54,24 @@ describe("book reducer", () => { 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 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 = { @@ -89,23 +92,23 @@ describe("book reducer", () => { 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); - }); + // 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/tests/jest/features/book.test.ts b/tests/jest/features/book.test.ts new file mode 100644 index 000000000..7ff9db8f3 --- /dev/null +++ b/tests/jest/features/book.test.ts @@ -0,0 +1,171 @@ +import reducer, { + initialState, + getBookData, +} 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"; + +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" }; + + 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 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; + }); + }); + + describe("thunks...", () => { + describe("getBookData...", () => { + const fetchBook = (url: string) => store.dispatch(getBookData({ url })); + + const goodBookUrl = "https://example.com/book"; + const brokenBookUrl = "https://example.com/broken-book"; + const errorBookUrl = "https://example.com/error-book"; + + 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.restore() + // }); + afterAll(() => { + fetchMock.reset(); + }); + + it("should return the book data on the happy path", async () => { + const result = await fetchBook(goodBookUrl); + const payload = result.payload as BookData; + + expect(result.type).to.equal(getBookData.fulfilled.type); + expect(result.meta.arg).to.deep.equal({ url: goodBookUrl }); + 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" + ); + }); + it("should return an error, if the data is malformed", async () => { + const result = await fetchBook(brokenBookUrl); + console.log("result", result); + const payload = result.payload as RequestError; + + expect(result.type).to.equal(getBookData.rejected.type); + expect(result.meta.arg).to.deep.equal({ url: brokenBookUrl }); + expect(payload.response).to.equal(FETCH_OPDS_PARSE_ERROR_MESSAGE); + expect(payload.url).to.equal(brokenBookUrl); + }); + it("should return an error, if the HTTP request fails", async () => { + const result = await fetchBook(errorBookUrl); + 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); + }); + }); + }); +}); From 0fb56fb72141353e9289badcf04acea900649adc Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Mon, 24 Jun 2024 11:12:46 -0400 Subject: [PATCH 11/18] Update `fetchMock` to current version and fix tests. --- package-lock.json | 53 +++++++---------------------------- package.json | 2 +- src/__tests__/actions-test.ts | 49 +++++++++++++++++++++----------- 3 files changed, 43 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4685ac277..e40b8f305 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 be97dce0b..2f92cba91 100644 --- a/package.json +++ b/package.json @@ -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 6f9f22aec..7ba527df2 100644 --- a/src/__tests__/actions-test.ts +++ b/src/__tests__/actions-test.ts @@ -54,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}` @@ -83,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}` @@ -110,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); @@ -215,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}` @@ -296,10 +300,11 @@ describe("actions", () => { const formData = new (window as any).FormData(); formData.append("title", "title"); - fetchMock.mock(editBookUrl, "done"); - const fetchArgs = fetchMock.calls(); + fetchMock.post(editBookUrl, "done"); await actions.editBook(editBookUrl, formData)(dispatch); + const fetchArgs = fetchMock.calls(); + expect(dispatch.callCount).to.equal(3); expect(dispatch.args[0][0].type).to.equal( ActionCreator.EDIT_BOOK_REQUEST @@ -355,9 +360,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 @@ -380,9 +386,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 @@ -435,12 +442,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 @@ -608,9 +616,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}` @@ -662,9 +671,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}` @@ -716,9 +726,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}` @@ -772,9 +783,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}` @@ -797,9 +809,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}` @@ -827,9 +840,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}` @@ -944,9 +958,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}` From 4f19bea34f76e20fa4e362dac1d6a340b489688a Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Mon, 24 Jun 2024 17:36:08 -0400 Subject: [PATCH 12/18] WIP - bookEditorSlice working / tests passing / some old tests commented out. --- src/__tests__/actions-test.ts | 51 ++-- src/actions.ts | 12 +- src/api/submitForm.ts | 71 ++++++ src/components/BookDetailsEditor.tsx | 29 +-- src/components/BookDetailsTabContainer.tsx | 2 +- src/components/ClassificationsForm.tsx | 2 +- .../__tests__/ClassificationsForm-test.tsx | 19 +- src/features/book/bookEditorSlice.ts | 52 +++- src/reducers/__tests__/book-test.ts | 61 ++--- src/reducers/book.ts | 28 +-- tests/jest/features/book.test.ts | 223 ++++++++++++++++-- 11 files changed, 416 insertions(+), 134 deletions(-) create mode 100644 src/api/submitForm.ts diff --git a/src/__tests__/actions-test.ts b/src/__tests__/actions-test.ts index 7ba527df2..e60107b7a 100644 --- a/src/__tests__/actions-test.ts +++ b/src/__tests__/actions-test.ts @@ -293,31 +293,32 @@ describe("actions", () => { }); }); - 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.post(editBookUrl, "done"); - - await actions.editBook(editBookUrl, formData)(dispatch); - const fetchArgs = fetchMock.calls(); - - 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); - }); - }); + // TODO: add tests for editBook actions + // 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.post(editBookUrl, "done"); + // + // await actions.editBook(editBookUrl, formData)(dispatch); + // const fetchArgs = fetchMock.calls(); + // + // 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 () => { diff --git a/src/actions.ts b/src/actions.ts index 1697f7a7f..578ccac3f 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -131,9 +131,9 @@ 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 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"; @@ -333,9 +333,9 @@ export default class ActionCreator extends BaseActionCreator { // 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); - } + // editBook(url: string, data: FormData | null) { + // return this.postForm(ActionCreator.EDIT_BOOK, url, data).bind(this); + // } fetchRoles() { const url = "/admin/roles"; 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/BookDetailsEditor.tsx b/src/components/BookDetailsEditor.tsx index 0eb43ca7e..23f7f5229 100644 --- a/src/components/BookDetailsEditor.tsx +++ b/src/components/BookDetailsEditor.tsx @@ -9,27 +9,7 @@ import ErrorMessage from "./ErrorMessage"; import { AppDispatch, RootState } from "../store"; import { Button } from "library-simplified-reusable-components"; import UpdatingLoader from "./UpdatingLoader"; -import { getBookData } from "../features/book/bookEditorSlice"; -import { AsyncThunkConfig } from "@reduxjs/toolkit/dist/createAsyncThunk"; - -// 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; @@ -175,7 +155,7 @@ function mapStateToProps( state.editor.roles.fetchError || state.editor.media.fetchError || state.editor.languages.fetchError, - editError: state.editor.book.editError, + editError: state.bookEditor.editError, }; } @@ -186,8 +166,9 @@ function mapDispatchToProps( 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(getBookData({ url })), // dispatch(actions.fetchBookAdmin(url)), + editBook: (url: string, data) => + dispatch(submitBookData({ url, data, csrfToken: ownProps.csrfToken })), + fetchBook: (url: string) => dispatch(getBookData({ url })), fetchRoles: () => dispatch(actions.fetchRoles()), fetchMedia: () => dispatch(actions.fetchMedia()), fetchLanguages: () => dispatch(actions.fetchLanguages()), diff --git a/src/components/BookDetailsTabContainer.tsx b/src/components/BookDetailsTabContainer.tsx index bf3af5577..76103f36f 100644 --- a/src/components/BookDetailsTabContainer.tsx +++ b/src/components/BookDetailsTabContainer.tsx @@ -145,7 +145,7 @@ function mapStateToProps(state: RootState) { return { complaintsCount: complaintsCount, - bookData: state.editor.book.data, + bookData: state.bookEditor.data, }; } diff --git a/src/components/ClassificationsForm.tsx b/src/components/ClassificationsForm.tsx index 7e3f17d23..99e5c1069 100644 --- a/src/components/ClassificationsForm.tsx +++ b/src/components/ClassificationsForm.tsx @@ -236,7 +236,7 @@ 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() + new Set(newBook.categories) !== new Set(this.props.book.categories) ); } diff --git a/src/components/__tests__/ClassificationsForm-test.tsx b/src/components/__tests__/ClassificationsForm-test.tsx index 1dec3689e..7300d5da4 100644 --- a/src/components/__tests__/ClassificationsForm-test.tsx +++ b/src/components/__tests__/ClassificationsForm-test.tsx @@ -402,14 +402,15 @@ describe("ClassificationsForm", () => { expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); }); - it("doesn't update state upoen receiving new state-unrelated props", () => { - // state updated with new form inputs - wrapper.setState({ fiction: false, genres: ["Cooking"] }); - // form submitted, disabling form - wrapper.setProps({ disabled: true }); - // state should not change back to earlier book props - expect(wrapper.state("fiction")).to.equal(false); - expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); - }); + // TODO: Fix this test + // 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 + // wrapper.setProps({ disabled: true }); + // // state should not change back to earlier book props + // expect(wrapper.state("fiction")).to.equal(false); + // expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); + // }); }); }); diff --git a/src/features/book/bookEditorSlice.ts b/src/features/book/bookEditorSlice.ts index 8ea6b5963..a2c335084 100644 --- a/src/features/book/bookEditorSlice.ts +++ b/src/features/book/bookEditorSlice.ts @@ -4,6 +4,8 @@ import DataFetcher, { RequestError, } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import editorAdapter from "../../editorAdapter"; +import { submitForm } from "../../api/submitForm"; +import { RootState } from "../../store"; export interface BookState { url: string; @@ -62,6 +64,21 @@ const bookEditorSlice = createSlice({ state.isFetching = false; state.fetchError = action.payload as RequestError; }) + .addCase(submitBookData.pending, (state, action) => { + // console.log("submitBookData.pending", { action, state }); + state.isFetching = true; + state.editError = null; + }) + .addCase(submitBookData.fulfilled, (state, action) => { + // console.log("submitBookData.fulfilled", { action, state }); + state.isFetching = false; + state.editError = null; + }) + .addCase(submitBookData.rejected, (state, action) => { + // console.log("submitBookData.rejected", { action, state }); + state.isFetching = true; + state.editError = action.payload as RequestError; + }) .addMatcher( (action) => true, (state, action) => { @@ -71,17 +88,44 @@ const bookEditorSlice = createSlice({ }, }); +export type GetBookDataArgs = { + url: string; +}; + export const getBookData = createAsyncThunk( bookEditorSlice.reducerPath + "/getBookData", - async ({ url }: { url: string }, thunkAPI) => { - // console.log("getBookData thunkAPI", thunkAPI); + async ({ url }: GetBookDataArgs, thunkAPI) => { const fetcher = new DataFetcher({ adapter: editorAdapter }); try { const result = await fetcher.fetchOPDSData(url); - // console.log(bookEditorSlice.reducerPath + "/getBookData()", {url, result}); return result; } catch (e) { - // console.log(bookEditorSlice.reducerPath + "/getBookData()", {url, 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); } } diff --git a/src/reducers/__tests__/book-test.ts b/src/reducers/__tests__/book-test.ts index a116828f3..ca9e660ce 100644 --- a/src/reducers/__tests__/book-test.ts +++ b/src/reducers/__tests__/book-test.ts @@ -30,10 +30,11 @@ describe("book reducer", () => { 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); - }); + // TODO: test clearBook + // 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" }; @@ -46,13 +47,14 @@ describe("book reducer", () => { // 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); - }); + // TODO: test editBook + // 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 = { @@ -73,24 +75,25 @@ describe("book reducer", () => { // 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); - }); + // TODO: test editBook + // 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 = { diff --git a/src/reducers/book.ts b/src/reducers/book.ts index 381dcaac6..48ecca028 100644 --- a/src/reducers/book.ts +++ b/src/reducers/book.ts @@ -41,20 +41,20 @@ export default (state: BookState = initialState, action) => { // isFetching: false, // }); - case ActionCreator.BOOK_CLEAR: - return initialState; - - case ActionCreator.EDIT_BOOK_REQUEST: - return Object.assign({}, state, { - isFetching: true, - editError: null, - }); - - case ActionCreator.EDIT_BOOK_FAILURE: - return Object.assign({}, state, { - editError: action.error, - isFetching: false, - }); + // case ActionCreator.BOOK_CLEAR: + // return initialState; + // + // case ActionCreator.EDIT_BOOK_REQUEST: + // return Object.assign({}, state, { + // isFetching: true, + // editError: null, + // }); + // + // case ActionCreator.EDIT_BOOK_FAILURE: + // return Object.assign({}, state, { + // editError: action.error, + // isFetching: false, + // }); default: return state; diff --git a/tests/jest/features/book.test.ts b/tests/jest/features/book.test.ts index 7ff9db8f3..609db94a6 100644 --- a/tests/jest/features/book.test.ts +++ b/tests/jest/features/book.test.ts @@ -1,12 +1,15 @@ 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"; const SAMPLE_BOOK_ADMIN_DETAIL = ` @@ -116,55 +119,233 @@ describe("Redux bookEditorSlice...", () => { describe("thunks...", () => { describe("getBookData...", () => { - const fetchBook = (url: string) => store.dispatch(getBookData({ url })); - const goodBookUrl = "https://example.com/book"; const brokenBookUrl = "https://example.com/broken-book"; const errorBookUrl = "https://example.com/error-book"; - 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 }); + 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.restore() - // }); - afterAll(() => { - fetchMock.reset(); + afterEach(() => { + fetchMock.resetHistory(); + dispatch.mockClear(); }); + afterAll(() => fetchMock.restore()); it("should return the book data on the happy path", async () => { - const result = await fetchBook(goodBookUrl); - const payload = result.payload as BookData; + const action = getBookData({ url: goodBookUrl }); + + const result = await action(dispatch, getState, undefined); + const dispatchCalls = dispatch.mock.calls; - expect(result.type).to.equal(getBookData.fulfilled.type); - expect(result.meta.arg).to.deep.equal({ url: goodBookUrl }); + 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 result = await fetchBook(brokenBookUrl); - console.log("result", result); - const payload = result.payload as RequestError; + const action = getBookData({ url: brokenBookUrl }); - expect(result.type).to.equal(getBookData.rejected.type); - expect(result.meta.arg).to.deep.equal({ 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 result = await fetchBook(errorBookUrl); + 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.", + }; + + // 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); + // }); + + 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." + ); }); }); }); From 588a6ec2c2f06aef58f722dadc16e324d559ccf9 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Tue, 25 Jun 2024 18:01:18 -0400 Subject: [PATCH 13/18] WIP 2024-06-25 - bookEditorSlice working / more tests / some old tests commented out. --- src/actions.ts | 17 ----------------- src/components/BookDetailsEditor.tsx | 24 ++++++++++++------------ src/features/book/bookEditorSlice.ts | 20 ++++++++++---------- src/reducers/__tests__/book-test.ts | 18 ++++++++++-------- tests/jest/features/book.test.ts | 15 +++++++++++++++ 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index 578ccac3f..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/components/BookDetailsEditor.tsx b/src/components/BookDetailsEditor.tsx index 23f7f5229..bd8813145 100644 --- a/src/components/BookDetailsEditor.tsx +++ b/src/components/BookDetailsEditor.tsx @@ -26,7 +26,7 @@ export type BookDetailsEditorProps = ConnectedProps & 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); @@ -36,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(); @@ -46,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); } } @@ -100,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} /> )} @@ -114,24 +114,24 @@ 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); } } @@ -166,9 +166,9 @@ function mapDispatchToProps( const fetcher = new DataFetcher({ adapter: editorAdapter }); const actions = new ActionCreator(fetcher, ownProps.csrfToken); return { - editBook: (url: string, data) => + postBookData: (url: string, data) => dispatch(submitBookData({ url, data, csrfToken: ownProps.csrfToken })), - fetchBook: (url: string) => dispatch(getBookData({ url })), + fetchBookData: (url: string) => dispatch(getBookData({ url })), fetchRoles: () => dispatch(actions.fetchRoles()), fetchMedia: () => dispatch(actions.fetchMedia()), fetchLanguages: () => dispatch(actions.fetchLanguages()), diff --git a/src/features/book/bookEditorSlice.ts b/src/features/book/bookEditorSlice.ts index a2c335084..5f237ed5c 100644 --- a/src/features/book/bookEditorSlice.ts +++ b/src/features/book/bookEditorSlice.ts @@ -6,6 +6,7 @@ import DataFetcher, { import editorAdapter from "../../editorAdapter"; import { submitForm } from "../../api/submitForm"; import { RootState } from "../../store"; +import ActionCreator from "../../actions"; export interface BookState { url: string; @@ -33,13 +34,17 @@ interface InitialState { const bookEditorSlice = createSlice({ name: "bookEditor", initialState, - reducers: { - bookCleared(state, action) { - state = initialState; - }, - }, + reducers: {}, extraReducers: (builder) => { builder + .addCase(ActionCreator.BOOK_CLEAR, (state, action) => { + // Handle resetting the book data via actions from the web-opds-client. + console.log("*** Handling clear book data action ***", action.type, { + action, + state, + }); + return initialState; + }) .addCase(getBookData.pending, (state, action) => { // console.log("getBookData.pending", { action, state }); const { url } = action.meta.arg; @@ -49,7 +54,6 @@ const bookEditorSlice = createSlice({ state.fetchError = null; }) .addCase(getBookData.fulfilled, (state, action) => { - // console.log("getBookData.fulfilled", { action, state }); const { url } = action.meta.arg; state.url = url; state.data = action.payload as BookData; @@ -57,7 +61,6 @@ const bookEditorSlice = createSlice({ state.fetchError = null; }) .addCase(getBookData.rejected, (state, action) => { - // console.log("getBookData.rejected", { action, state }); const { url } = action.meta.arg; state.url = url; state.data = null; @@ -65,17 +68,14 @@ const bookEditorSlice = createSlice({ state.fetchError = action.payload as RequestError; }) .addCase(submitBookData.pending, (state, action) => { - // console.log("submitBookData.pending", { action, state }); state.isFetching = true; state.editError = null; }) .addCase(submitBookData.fulfilled, (state, action) => { - // console.log("submitBookData.fulfilled", { action, state }); state.isFetching = false; state.editError = null; }) .addCase(submitBookData.rejected, (state, action) => { - // console.log("submitBookData.rejected", { action, state }); state.isFetching = true; state.editError = action.payload as RequestError; }) diff --git a/src/reducers/__tests__/book-test.ts b/src/reducers/__tests__/book-test.ts index ca9e660ce..bff2e3f67 100644 --- a/src/reducers/__tests__/book-test.ts +++ b/src/reducers/__tests__/book-test.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; -import book from "../book"; +// import book from "../book"; +import book from "../../features/book/bookEditorSlice"; import ActionCreator from "../../actions"; import bookEditorSlice, { getBookData, @@ -26,15 +27,16 @@ describe("book reducer", () => { editError: null, }; - it("returns initial state for unrecognized action", () => { - expect(book(undefined, {})).to.deep.equal(initState); - }); + // it("returns initial state for unrecognized action", () => { + // expect(book(undefined, {})).to.deep.equal(initState); + // }); // TODO: test clearBook - // it("handles CLEAR_BOOK", () => { - // const action = { type: ActionCreator.BOOK_CLEAR }; - // expect(book(fetchedState, action)).to.deep.equal(initState); - // }); + it("handles CLEAR_BOOK", () => { + const action = { type: ActionCreator.BOOK_CLEAR }; + console.log("result", book(fetchedState, action)); + expect(book(fetchedState, action)).to.deep.equal(initState); + }); // it("handles BOOK_ADMIN_REQUEST", () => { // const action = { type: ActionCreator.BOOK_ADMIN_REQUEST, url: "test url" }; diff --git a/tests/jest/features/book.test.ts b/tests/jest/features/book.test.ts index 609db94a6..1129b61d2 100644 --- a/tests/jest/features/book.test.ts +++ b/tests/jest/features/book.test.ts @@ -10,6 +10,7 @@ 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 = ` @@ -49,6 +50,14 @@ 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( @@ -60,6 +69,12 @@ describe("Redux bookEditorSlice...", () => { initialState ); }); + it("should handle BOOK_CLEAR", () => { + 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, From 42a199b134d7015af51743049a3a3a4c8cb5ae2f Mon Sep 17 00:00:00 2001 From: GitHub -- tdilauro Date: Tue, 25 Jun 2024 20:08:09 -0400 Subject: [PATCH 14/18] Clean up store. --- src/components/ContextProvider.tsx | 6 +----- src/index.tsx | 4 +--- src/store.ts | 25 ++----------------------- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/src/components/ContextProvider.tsx b/src/components/ContextProvider.tsx index 789aedd22..e5487d406 100644 --- a/src/components/ContextProvider.tsx +++ b/src/components/ContextProvider.tsx @@ -31,11 +31,7 @@ export default class ContextProvider extends React.Component< constructor(props) { super(props); - this.store = - props.store ?? - buildStore({ - csrfToken: props.csrfToken, - }); + this.store = props.store ?? buildStore(); this.admin = new Admin(props.roles || [], props.email || null); this.pathFor = (collectionUrl: string, bookUrl: string, tab?: string) => { let path = "/admin/web"; diff --git a/src/index.tsx b/src/index.tsx index e570ba8a1..25fa86bf3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -72,9 +72,7 @@ class CirculationAdmin { const queryClient = new QueryClient(); - const store = buildStore({ - csrfToken: config.csrfToken, - }); + const store = buildStore(); const appElement = "opds-catalog"; const app = config.settingUp ? ( diff --git a/src/store.ts b/src/store.ts index 1c004d2d2..b276ea6f7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,6 +1,5 @@ -import { configureStore, Store } from "@reduxjs/toolkit"; +import { configureStore } from "@reduxjs/toolkit"; -// import { apiSlice } from "./features/admin/admin-api-slice"; 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"; @@ -11,35 +10,15 @@ export interface CombinedState { catalog: CatalogState; } -type BuildStoreArgs = { - initialState?: CombinedState; - csrfToken?: string; -}; - -export type ThunkExtraArgument = { - csrfToken: string; -}; - /** Build a redux store with reducers specific to the admin interface as well as reducers from web-opds-client. */ -export default function buildStore({ - initialState, - csrfToken, -}: BuildStoreArgs = {}) { - // console.log("buildStore", initialState, csrfToken); +export default function buildStore(initialState?: CombinedState) { return configureStore({ reducer: { editor: editorReducers, catalog: catalogReducers, bookEditor: bookEditorSlice, - // [apiSlice.reducerPath]: apiSlice.reducer, }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ - thunk: { - extraArgument: { csrfToken: "...csrfToken-text-here..." }, - }, - }), // .concat(apiSlice.middleware), preloadedState: initialState, devTools: process.env.NODE_ENV !== "production", }); From d35af445c1dbb0981b0d43c5bc7b7348ac7e4668 Mon Sep 17 00:00:00 2001 From: GitHub -- tdilauro Date: Tue, 25 Jun 2024 20:51:00 -0400 Subject: [PATCH 15/18] Clean up tests. --- .../__tests__/BookDetailsEditor-test.tsx | 20 +-- src/features/book/bookEditorSlice.ts | 10 +- src/reducers/__tests__/book-test.ts | 119 ------------------ tests/jest/features/book.test.ts | 36 ++++++ 4 files changed, 48 insertions(+), 137 deletions(-) delete mode 100644 src/reducers/__tests__/book-test.ts 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/features/book/bookEditorSlice.ts b/src/features/book/bookEditorSlice.ts index 5f237ed5c..a4b10cf2d 100644 --- a/src/features/book/bookEditorSlice.ts +++ b/src/features/book/bookEditorSlice.ts @@ -76,15 +76,9 @@ const bookEditorSlice = createSlice({ state.editError = null; }) .addCase(submitBookData.rejected, (state, action) => { - state.isFetching = true; + state.isFetching = false; state.editError = action.payload as RequestError; - }) - .addMatcher( - (action) => true, - (state, action) => { - // console.log("Unhandled action", action.type, {action, state}); - } - ); + }); }, }); diff --git a/src/reducers/__tests__/book-test.ts b/src/reducers/__tests__/book-test.ts deleted file mode 100644 index bff2e3f67..000000000 --- a/src/reducers/__tests__/book-test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { expect } from "chai"; - -// import book from "../book"; -import book from "../../features/book/bookEditorSlice"; -import ActionCreator from "../../actions"; -import bookEditorSlice, { - getBookData, -} from "../../features/book/bookEditorSlice"; - -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); - // }); - - // TODO: test clearBook - it("handles CLEAR_BOOK", () => { - const action = { type: ActionCreator.BOOK_CLEAR }; - console.log("result", book(fetchedState, action)); - 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); - // }); - - // TODO: test editBook - // 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); - // }); - - // TODO: test editBook - // 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/tests/jest/features/book.test.ts b/tests/jest/features/book.test.ts index 1129b61d2..311b52bfc 100644 --- a/tests/jest/features/book.test.ts +++ b/tests/jest/features/book.test.ts @@ -130,6 +130,42 @@ describe("Redux bookEditorSlice...", () => { 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...", () => { From 7165959a75245b3930990989ad031714daa41075 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Wed, 26 Jun 2024 13:10:09 -0400 Subject: [PATCH 16/18] Code clean up. --- src/__tests__/actions-test.ts | 27 -------- src/components/BookDetailsTabContainer.tsx | 5 -- src/components/ClassificationsForm.tsx | 2 +- .../__tests__/ClassificationsForm-test.tsx | 19 +++--- src/features/apiSlice.ts | 10 --- src/features/book/bookEditorSlice.ts | 5 -- src/reducers/book.ts | 62 ------------------- src/reducers/index.ts | 3 - tests/jest/features/book.test.ts | 9 +-- 9 files changed, 11 insertions(+), 131 deletions(-) delete mode 100644 src/features/apiSlice.ts delete mode 100644 src/reducers/book.ts diff --git a/src/__tests__/actions-test.ts b/src/__tests__/actions-test.ts index e60107b7a..6db166a8c 100644 --- a/src/__tests__/actions-test.ts +++ b/src/__tests__/actions-test.ts @@ -293,33 +293,6 @@ describe("actions", () => { }); }); - // TODO: add tests for editBook actions - // 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.post(editBookUrl, "done"); - // - // await actions.editBook(editBookUrl, formData)(dispatch); - // const fetchArgs = fetchMock.calls(); - // - // 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(); diff --git a/src/components/BookDetailsTabContainer.tsx b/src/components/BookDetailsTabContainer.tsx index 76103f36f..1c5cdd9c9 100644 --- a/src/components/BookDetailsTabContainer.tsx +++ b/src/components/BookDetailsTabContainer.tsx @@ -7,10 +7,8 @@ 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"; -import { Store } from "@reduxjs/toolkit"; interface BookDetailsTabContainerOwnProps extends TabContainerProps { bookUrl: string; @@ -45,9 +43,6 @@ export class BookDetailsTabContainer extends TabContainer< BookDetailsTabContainerProps > { componentWillUnmount() { - console.log( - "\n\nUnmounting BookDetailsTabContainer... and clearing book data\n\n" - ); this.props.clearBook(); } diff --git a/src/components/ClassificationsForm.tsx b/src/components/ClassificationsForm.tsx index 99e5c1069..7e3f17d23 100644 --- a/src/components/ClassificationsForm.tsx +++ b/src/components/ClassificationsForm.tsx @@ -236,7 +236,7 @@ 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 || - new Set(newBook.categories) !== new Set(this.props.book.categories) + newBook.categories.sort() !== this.props.book.categories.sort() ); } diff --git a/src/components/__tests__/ClassificationsForm-test.tsx b/src/components/__tests__/ClassificationsForm-test.tsx index 7300d5da4..56f17766e 100644 --- a/src/components/__tests__/ClassificationsForm-test.tsx +++ b/src/components/__tests__/ClassificationsForm-test.tsx @@ -402,15 +402,14 @@ describe("ClassificationsForm", () => { expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); }); - // TODO: Fix this test - // 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 - // wrapper.setProps({ disabled: true }); - // // state should not change back to earlier book props - // expect(wrapper.state("fiction")).to.equal(false); - // expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); - // }); + 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 + wrapper.setProps({ disabled: true }); + // state should not change back to earlier book props + expect(wrapper.state("fiction")).to.equal(false); + expect(wrapper.state("genres")).to.deep.equal(["Cooking"]); + }); }); }); diff --git a/src/features/apiSlice.ts b/src/features/apiSlice.ts deleted file mode 100644 index 24695683f..000000000 --- a/src/features/apiSlice.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; - -// export const api = createApi({ -// baseQuery: fetchBaseQuery({ -// baseUrl: "" -// }), -// endpoints: (builder) => ({ -// -// }, -// }); diff --git a/src/features/book/bookEditorSlice.ts b/src/features/book/bookEditorSlice.ts index a4b10cf2d..d8848e5c1 100644 --- a/src/features/book/bookEditorSlice.ts +++ b/src/features/book/bookEditorSlice.ts @@ -39,14 +39,9 @@ const bookEditorSlice = createSlice({ builder .addCase(ActionCreator.BOOK_CLEAR, (state, action) => { // Handle resetting the book data via actions from the web-opds-client. - console.log("*** Handling clear book data action ***", action.type, { - action, - state, - }); return initialState; }) .addCase(getBookData.pending, (state, action) => { - // console.log("getBookData.pending", { action, state }); const { url } = action.meta.arg; state.url = url; state.data = null; diff --git a/src/reducers/book.ts b/src/reducers/book.ts deleted file mode 100644 index 48ecca028..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_ADMIN_REQUEST: - // return Object.assign({}, state, { - // url: action.url, - // isFetching: true, - // fetchError: null, - // 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.BOOK_CLEAR: - // return initialState; - // - // case ActionCreator.EDIT_BOOK_REQUEST: - // return Object.assign({}, state, { - // isFetching: true, - // editError: null, - // }); - // - // 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/tests/jest/features/book.test.ts b/tests/jest/features/book.test.ts index 311b52bfc..a3249d9d1 100644 --- a/tests/jest/features/book.test.ts +++ b/tests/jest/features/book.test.ts @@ -70,6 +70,7 @@ describe("Redux bookEditorSlice...", () => { ); }); 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); @@ -285,14 +286,6 @@ describe("Redux bookEditorSlice...", () => { detail: "There was an error saving your changes.", }; - // 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); - // }); - const dispatch = jest.fn(); const getState = jest.fn().mockReturnValue({ bookEditor: initialState, From 6c69f64806a7370a6b4d209aee250ca16a878155 Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Wed, 26 Jun 2024 13:17:02 -0400 Subject: [PATCH 17/18] Careful. --- src/components/ClassificationsForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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("::") ); } From 9c83d8e6983530bc84754804f39664c10692baef Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Wed, 26 Jun 2024 17:16:05 -0400 Subject: [PATCH 18/18] Delete extra book slice file. --- src/features/book/bookSlice.ts | 65 ---------------------------------- 1 file changed, 65 deletions(-) delete mode 100644 src/features/book/bookSlice.ts diff --git a/src/features/book/bookSlice.ts b/src/features/book/bookSlice.ts deleted file mode 100644 index 553b745c9..000000000 --- a/src/features/book/bookSlice.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createSlice } from "@reduxjs/toolkit"; -import { BookData } from "../../interfaces"; -import { RequestError } from "@thepalaceproject/web-opds-client/lib/DataFetcher"; - -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, -}; - -const bookSlice = createSlice({ - name: "book", - initialState, - reducers: { - bookCleared(state) { - state = initialState; - }, - bookAdminRequested(state, action) { - const { url } = action.payload; - state = { - ...state, - url, - isFetching: true, - fetchError: null, - editError: null, - }; - }, - bookAdminLoaded(state, action) { - const { url, data } = action.payload; - state = { ...state, url, data, isFetching: false }; - }, - bookAdminFailed(state, action) { - const { url, error } = action.payload; - state = { ...state, url, isFetching: false, fetchError: error }; - }, - editBookRequested(state, action) { - state = { ...state, isFetching: true, editError: null }; - }, - editBookFailed(state, action) { - const { error } = action.payload; - state = { ...state, isFetching: false, editError: error }; - }, - }, -}); - -export const { - bookCleared, - bookAdminRequested, - bookAdminLoaded, - bookAdminFailed, - editBookRequested, - editBookFailed, -} = bookSlice.actions; - -export default bookSlice.reducer;