From b0e8130d18718c03876130a097a0dc11ac5c5e5c Mon Sep 17 00:00:00 2001 From: Tim DiLauro Date: Fri, 28 Jun 2024 13:37:25 -0400 Subject: [PATCH] suppress/unsuppress books on per-library basis --- src/components/BookDetailsEditor.tsx | 115 ++++++++++++---- .../BookDetailsEditorSuppression.tsx | 123 ++++++++++++++++++ .../ConfirmationModalWithOutcome.tsx | 118 +++++++++++++++++ src/components/ErrorMessage.tsx | 14 +- src/editorAdapter.ts | 57 ++++++-- src/features/api/apiSlice.ts | 8 ++ src/features/book/bookEditorSlice.ts | 55 ++++++-- src/interfaces.ts | 2 + src/store.ts | 6 + 9 files changed, 448 insertions(+), 50 deletions(-) create mode 100644 src/components/BookDetailsEditorSuppression.tsx create mode 100644 src/components/ConfirmationModalWithOutcome.tsx create mode 100644 src/features/api/apiSlice.ts diff --git a/src/components/BookDetailsEditor.tsx b/src/components/BookDetailsEditor.tsx index bd8813145..750e10c79 100644 --- a/src/components/BookDetailsEditor.tsx +++ b/src/components/BookDetailsEditor.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { AsyncThunkAction, Store } from "@reduxjs/toolkit"; +import { Store } from "@reduxjs/toolkit"; import { connect, ConnectedProps } from "react-redux"; import DataFetcher from "@thepalaceproject/web-opds-client/lib/DataFetcher"; import ActionCreator from "../actions"; @@ -10,6 +10,8 @@ import { AppDispatch, RootState } from "../store"; import { Button } from "library-simplified-reusable-components"; import UpdatingLoader from "./UpdatingLoader"; import { getBookData, submitBookData } from "../features/book/bookEditorSlice"; +import BookDetailsEditorSuppression from "./BookDetailsEditorSuppression"; +import { bookEditorApiEndpoints } from "../features/book/bookEditorSlice"; export interface BookDetailsEditorOwnProps { bookUrl?: string; @@ -24,7 +26,7 @@ export type BookDetailsEditorProps = ConnectedProps & /** Tab for editing a book's metadata on the book details page. */ export class BookDetailsEditor extends React.Component { - constructor(props) { + constructor(props: BookDetailsEditorProps) { super(props); this.postWithoutPayload = this.postWithoutPayload.bind(this); this.hide = this.hide.bind(this); @@ -43,17 +45,18 @@ export class BookDetailsEditor extends React.Component { } } - UNSAFE_componentWillReceiveProps(nextProps) { + UNSAFE_componentWillReceiveProps(nextProps: BookDetailsEditorProps) { if (nextProps.bookUrl && nextProps.bookUrl !== this.props.bookUrl) { const bookAdminUrl = nextProps.bookUrl.replace("works", "admin/works"); this.props.fetchBookData(bookAdminUrl); } } - render(): JSX.Element { + render(): React.ReactElement { + const { bookData } = this.props; return (
- {this.props.bookData && !this.props.fetchError && ( + {bookData && !this.props.fetchError && ( <>

{this.props.bookData.title}

@@ -63,27 +66,73 @@ export class BookDetailsEditor extends React.Component { )} - {(this.props.bookData.hideLink || - this.props.bookData.restoreLink || - this.props.bookData.refreshLink) && ( + {(bookData.suppressPerLibraryLink || + bookData.unsuppressPerLibraryLink || + bookData.refreshLink) && (
- {this.props.bookData.hideLink && ( -
)} - - {this.props.bookData.editLink && ( + {bookData.editLink && ( { this.props.refreshCatalog(); } - postWithoutPayload(url) { + postWithoutPayload(url: string) { return this.props.postBookData(url, null).then(this.refresh); } } -function mapStateToProps( - state: RootState, - ownProps: BookDetailsEditorOwnProps -) { +function mapStateToProps(state: RootState) { return { bookAdminUrl: state.bookEditor.url, bookData: state.bookEditor.data, @@ -166,12 +211,26 @@ function mapDispatchToProps( const fetcher = new DataFetcher({ adapter: editorAdapter }); const actions = new ActionCreator(fetcher, ownProps.csrfToken); return { - postBookData: (url: string, data) => + postBookData: (url: string, data: FormData | null) => dispatch(submitBookData({ url, data, csrfToken: ownProps.csrfToken })), fetchBookData: (url: string) => dispatch(getBookData({ url })), fetchRoles: () => dispatch(actions.fetchRoles()), fetchMedia: () => dispatch(actions.fetchMedia()), fetchLanguages: () => dispatch(actions.fetchLanguages()), + suppressBook: (url: string) => + dispatch( + bookEditorApiEndpoints.endpoints.suppressBook.initiate({ + url, + csrfToken: ownProps.csrfToken, + }) + ), + unsuppressBook: (url: string) => + dispatch( + bookEditorApiEndpoints.endpoints.unsuppressBook.initiate({ + url, + csrfToken: ownProps.csrfToken, + }) + ), }; } diff --git a/src/components/BookDetailsEditorSuppression.tsx b/src/components/BookDetailsEditorSuppression.tsx new file mode 100644 index 000000000..de8e19737 --- /dev/null +++ b/src/components/BookDetailsEditorSuppression.tsx @@ -0,0 +1,123 @@ +import * as React from "react"; +import { Button } from "library-simplified-reusable-components"; +import { LinkData } from "../interfaces"; +import ConfirmationModalWithOutcome, { + SHOW_CONFIRMATION, + SHOW_OUTCOME, + ModalState, +} from "./ConfirmationModalWithOutcome"; +import ErrorMessage, { pdString } from "./ErrorMessage"; + +type BookDetailsEditorPerLibraryVisibilityProps = { + link: LinkData; + onConfirm: () => Promise; + onComplete: () => void; + buttonContent: string; + buttonTitle: string; + className?: string; + buttonDisabled: boolean; + confirmationTitle?: string; + confirmationBody?: React.ReactElement; + confirmationButtonContent?: string; + confirmationButtonTitle?: string; + dismissOutcomeButtonContent?: string; + dismissOutcomeButtonTitle?: string; + defaultSuccessMessage?: React.ReactElement; + defaultFailureMessage?: React.ReactElement; +}; + +const BookDetailsEditorSuppression = ({ + link, + onConfirm, + onComplete, + buttonContent, + buttonTitle = buttonContent, + className = "", + buttonDisabled, + confirmationTitle = "Confirm", + confirmationBody =

Are you sure?

, + confirmationButtonContent = "Confirm", + confirmationButtonTitle = "Confirm action.", + defaultSuccessMessage =

Success!

, + defaultFailureMessage =

Something went wrong.

, +}: BookDetailsEditorPerLibraryVisibilityProps) => { + const [modalState, setModalState] = React.useState(undefined); + const [outcomeMessage, setOutcomeMessage] = React.useState< + React.ReactElement + >(null); + const [confirmed, setConfirmed] = React.useState(false); + + const reset = () => { + confirmed && onComplete(); + setConfirmed(false); + setModalState(undefined); + setOutcomeMessage(null); + }; + const modalIsVisible = () => { + return !!modalState; + }; + const showModal = () => { + setModalState(SHOW_CONFIRMATION); + }; + const localOnConfirm = async () => { + onConfirm() + // @ts-expect-error - TODO: Fix type error, so TS knows that onConfirm() has an `unwrap()` method. + .unwrap() + .then((resolved) => { + setOutcomeMessage( + resolved?.message ? resolved.message : defaultSuccessMessage + ); + }) + .catch((error) => { + let errorMessage: React.ReactElement; + if (!error) { + errorMessage = defaultFailureMessage; + } else { + let response: string; + if (error.data?.detail && error.data?.title && error.data?.status) { + response = `${pdString}: ${JSON.stringify(error.data)}`; + } else { + response = `
${JSON.stringify(error, undefined, 2)}
`; + } + errorMessage = ( + + ); + } + setOutcomeMessage(errorMessage); + }); + setModalState(SHOW_OUTCOME); + setConfirmed(true); + }; + + return ( + <> + {!!link && ( +