diff --git a/.github/workflows/push-dev-docker-image.yml b/.github/workflows/push-dev-docker-image.yml index b572dd5269..f1b839ef4c 100644 --- a/.github/workflows/push-dev-docker-image.yml +++ b/.github/workflows/push-dev-docker-image.yml @@ -37,9 +37,6 @@ jobs: run: echo ${{ secrets.DOCKER_HUB_PASSWORD }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin continue-on-error: true - - uses: satackey/action-docker-layer-caching@v0.0.11 - continue-on-error: true - - name: Build development image run: | docker build \ diff --git a/.vscode/launch.json b/.vscode/launch.json index 4918136c29..6fceb9ff3d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,7 +27,7 @@ ], "env": {"NODE_ENV":"test","SSR":true}, "internalConsoleOptions": "openOnSessionStart", - "preLaunchTask": "build-website" + "preLaunchTask": "" }, { "type": "node", diff --git a/config/config.json.ctmpl b/config/config.json.ctmpl index 66d9c971f3..292cf83dd6 100644 --- a/config/config.json.ctmpl +++ b/config/config.json.ctmpl @@ -13,6 +13,11 @@ "clientSecret": "{{template "KEY" "musicbrainz/client_secret"}}", "callbackURL": "{{template "KEY" "musicbrainz/callback_url"}}" }, + "critiquebrainz": { + "clientID": "{{template "KEY" "critiquebrainz/client_id"}}", + "clientSecret": "{{template "KEY" "critiquebrainz/client_secret"}}", + "callbackURL": "{{template "KEY" "critiquebrainz/callback_url"}}" + }, "session": { {{if service "bookbrainz-redis"}} {{with index (service "bookbrainz-redis") 0}} diff --git a/config/config.json.example b/config/config.json.example index bdbbf1249e..5029865171 100644 --- a/config/config.json.example +++ b/config/config.json.example @@ -9,6 +9,11 @@ "clientSecret": null, "callbackURL": "http://localhost:9099/cb" }, + "critiquebrainz": { + "clientID": null, + "clientSecret": null, + "callbackURL": "http://localhost:9099/external-service/critiquebrainz/callback" + }, "session": { "maxAge": 2592000000, "secret": "Something here!", diff --git a/config/config.local.json.example b/config/config.local.json.example index 518ffeb958..86779446b9 100644 --- a/config/config.local.json.example +++ b/config/config.local.json.example @@ -9,6 +9,11 @@ "clientSecret": null, "callbackURL": "http://localhost:9099/cb" }, + "critiquebrainz": { + "clientID": null, + "clientSecret": null, + "callbackURL": "http://localhost:9099/external-service/critiquebrainz/callback" + }, "session": { "maxAge": 2592000000, "secret": "Something here!", diff --git a/enzyme.config.js b/enzyme.config.js new file mode 100644 index 0000000000..769336fed8 --- /dev/null +++ b/enzyme.config.js @@ -0,0 +1,17 @@ +/* eslint-disable node/no-process-env */ +/* Taken from: ListenBrainz */ +import * as Enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + + +Enzyme.configure({adapter: new Adapter()}); + +// In Node > v15 unhandled promise rejections will terminate the process +if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) { + process.on('unhandledRejection', (err) => { + // eslint-disable-next-line no-console + console.log('Unhandled promise rejection:', err); + }); + // Avoid memory leak by adding too many listeners + process.env.LISTENING_TO_UNHANDLED_REJECTION = 'true'; +} diff --git a/package.json b/package.json index 83a9118fc5..5c5fd6677b 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,7 @@ "private": true, "scripts": { "clean": "./scripts/clean.sh", - "prepublishOnly": "npm run clean", - "postinstall": "npm run prepublishOnly", + "prepare": "npm run clean", "build-server-js": "babel src --out-dir lib --source-maps --ignore src/api --extensions .js,.jsx,.ts,.tsx", "build-api-js": "babel src --out-dir lib --ignore 'src/server','src/client' --extensions .js,.jsx,.ts,.tsx", "build-scss": "./scripts/build-scss.sh", @@ -18,25 +17,26 @@ "debug-watch-server": "cross-env DEBUG=bbsite NODE_ENV=development nodemon src/server/app.js --ext js,jsx,ts,tsx --watch src/server --exec 'babel-node --extensions .js,.jsx,.ts,.tsx'", "lint": "eslint .", "lint-errors": "eslint --quiet .", - "test": "npm run lint-errors && cross-env NODE_ENV=test mocha", + "test": "npm run lint-errors && cross-env NODE_ENV=test mocha -r jsdom-global/register", "test-cov": "npx nyc --reporter=text npm run test", "test-ci": "NODE_ENV=test mocha --reporter json --reporter-option output=test-results.json", "jsdoc": "npx jsdoc -r src", "dupreport": "npx jsinspect src/ || true" }, "engines": { - "node": ">= 16.0.0" + "node": ">= 16.16.0" }, "browserslist": "> 0.25%, not dead", "dependencies": { "@babel/runtime": "^7.17.7", + "@cospired/i18n-iso-languages": "^4.0.0", "@elastic/elasticsearch": "^5.6.22", "@fortawesome/fontawesome-svg-core": "^1.2.30", - "@fortawesome/free-brands-svg-icons": "^5.14.0", - "@fortawesome/free-solid-svg-icons": "^5.14.0", + "@fortawesome/free-brands-svg-icons": "^6.1.1", + "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.11", "array-move": "^3.0.1", - "bookbrainz-data": "2.14.1", + "bookbrainz-data": "3.0.0", "chart.js": "^2.9.4", "chartjs-adapter-date-fns": "^1.0.0", "classnames": "^2.3.1", @@ -58,6 +58,7 @@ "lodash": "^4.17.21", "log": "^6.0.0", "log-node": "^8.0.3", + "mocha-chai-jest-snapshot": "^1.1.4", "morgan": "^1.10.0", "nodemailer": "^6.5.0", "passport": "^0.5.2", @@ -72,8 +73,10 @@ "react-redux": "^7.2.6", "react-select": "^4.3.1", "react-select-fast-filter-options": "^0.2.3", + "react-simple-star-rating": "^4.0.5", "react-sortable-hoc": "^2.0.0", "react-sticky": "^6.0.1", + "react-tooltip": "^4.2.21", "redis": "^3.1.2", "redux": "^3.7.2", "redux-debounce": "^1.0.1", @@ -82,7 +85,7 @@ "serve-favicon": "^2.4.3", "serve-static": "^1.14.1", "superagent": "^7.1.1", - "swagger-jsdoc": "^4.0.0", + "swagger-jsdoc": "^6.2.5", "swagger-ui-express": "^4.3.0", "validator": "^13.7.0" }, @@ -116,6 +119,8 @@ "clean-webpack-plugin": "^4.0.0", "compression-webpack-plugin": "^9.2.0", "css-loader": "^6.7.1", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.6", "eslint": "^7.30.0", "eslint-plugin-babel": "^5.3.1", "eslint-plugin-import": "^2.22.1", @@ -124,18 +129,22 @@ "eslint-webpack-plugin": "^2.4.1", "faker": "^4.1.0", "file-loader": "^6.2.0", + "jsdom": "20.0.0", + "jsdom-global": "3.0.2", "mini-css-extract-plugin": "^2.5.3", "mocha": "^9.1.3", "nodemon": "^2.0.2", + "redux-mock-store": "^1.5.4", "resolve-url-loader": "^5.0.0", "rewire": "^5.0.0", "sass": "^1.49.0", "sass-loader": "^12.4.0", + "sinon": "^14.0.0", "typescript": "^4.0.5", "uuid": "^8.3.2", "webpack": "^5.69.1", "webpack-bundle-analyzer": "^4.3.0", - "webpack-cli": "^4.3.0", + "webpack-cli": "^4.10.0", "webpack-dev-middleware": "^5.3.1", "webpack-hot-middleware": "^2.25.0" } diff --git a/sql/migrations/2022-07-19/up.sql b/sql/migrations/2022-07-19/up.sql new file mode 100644 index 0000000000..dc26f114d0 --- /dev/null +++ b/sql/migrations/2022-07-19/up.sql @@ -0,0 +1,21 @@ +BEGIN TRANSACTION; + +CREATE TYPE bookbrainz.external_service_oauth_type AS ENUM ( + 'critiquebrainz' +); + +CREATE TABLE bookbrainz.external_service_oauth ( + id SERIAL, + editor_id INTEGER NOT NULL, + service bookbrainz.external_service_oauth_type NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + token_expires TIMESTAMP, + scopes TEXT[] +); + +ALTER TABLE bookbrainz.external_service_oauth ADD CONSTRAINT external_service_oauth_editor_id_service UNIQUE (editor_id, service); + +ALTER TABLE bookbrainz.external_service_oauth ADD FOREIGN KEY (editor_id) REFERENCES bookbrainz.editor (id); + +COMMIT; \ No newline at end of file diff --git a/sql/schemas/bookbrainz.sql b/sql/schemas/bookbrainz.sql index 51c5fbe0d9..e0fdbe4755 100644 --- a/sql/schemas/bookbrainz.sql +++ b/sql/schemas/bookbrainz.sql @@ -16,6 +16,10 @@ CREATE TYPE bookbrainz.entity_type AS ENUM ( 'Series' ); +CREATE TYPE bookbrainz.external_service_oauth_type AS ENUM ( + 'critiquebrainz' +); + CREATE TABLE bookbrainz.editor_type ( id SERIAL PRIMARY KEY, label VARCHAR(255) NOT NULL CHECK (label <> '') @@ -806,6 +810,20 @@ CREATE TABLE bookbrainz.user_collection_collaborator ( ALTER TABLE bookbrainz.user_collection_collaborator ADD FOREIGN KEY (collection_id) REFERENCES bookbrainz.user_collection (id) ON DELETE CASCADE; ALTER TABLE bookbrainz.user_collection_collaborator ADD FOREIGN KEY (collaborator_id) REFERENCES bookbrainz.editor (id); +CREATE TABLE bookbrainz.external_service_oauth ( + id SERIAL, + editor_id INTEGER NOT NULL, + service bookbrainz.external_service_oauth_type NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + token_expires TIMESTAMP, + scopes TEXT[] +); + +ALTER TABLE bookbrainz.external_service_oauth ADD CONSTRAINT external_service_oauth_editor_id_service UNIQUE (editor_id, service); + +ALTER TABLE bookbrainz.external_service_oauth ADD FOREIGN KEY (editor_id) REFERENCES bookbrainz.editor (id); + -- Views -- CREATE VIEW bookbrainz.author AS diff --git a/src/client/components/author-credit-display.js b/src/client/components/author-credit-display.js index bb5e7fb454..478f13b928 100644 --- a/src/client/components/author-credit-display.js +++ b/src/client/components/author-credit-display.js @@ -22,14 +22,17 @@ import {map as _map} from 'lodash'; function AuthorCreditDisplay({names}) { - const nameElements = _map(names, (name) => ( - - - {name.name} - - {name.joinPhrase} - - )); + const nameElements = _map(names, (name) => { + const authorBBID = name.authorBBID ?? name.author?.id; + return ( + + + {name.name} + + {name.joinPhrase} + + ); + }); return ( diff --git a/src/client/components/pages/entities/author.js b/src/client/components/pages/entities/author.js index 4830276682..d2b14c75da 100644 --- a/src/client/components/pages/entities/author.js +++ b/src/client/components/pages/entities/author.js @@ -18,16 +18,19 @@ import * as bootstrap from 'react-bootstrap'; import * as entityHelper from '../../../helpers/entity'; +import React, {createRef, useCallback} from 'react'; +import AverageRating from './average-ratings'; +import CBReviewModal from './cbReviewModal'; import EntityAnnotation from './annotation'; import EntityFooter from './footer'; import EntityImage from './image'; import EntityLinks from './links'; import EntityRelatedCollections from './related-collections'; +import EntityReviews from './cb-review'; import EntityTitle from './title'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import PropTypes from 'prop-types'; -import React from 'react'; import {kebabCase as _kebabCase} from 'lodash'; import {faPlus} from '@fortawesome/free-solid-svg-icons'; import {labelsForAuthor} from '../../../helpers/utils'; @@ -48,6 +51,8 @@ function AuthorAttributes({author}) { const beginDate = transformISODateForDisplay(extractAttribute(author.beginDate)); const endDate = transformISODateForDisplay(extractAttribute(author.endDate)); const sortNameOfDefaultAlias = getSortNameOfDefaultAlias(author); + const averageRating = author.reviews?.reviews?.average_rating?.rating || 0; + const reviewsCount = author.reviews?.reviews?.average_rating?.count || 0; const isGroup = type === 'Group'; const { @@ -64,6 +69,10 @@ function AuthorAttributes({author}) {
Sort Name
{sortNameOfDefaultAlias}
+
@@ -108,9 +117,29 @@ AuthorAttributes.propTypes = { function AuthorDisplayPage({entity, identifierTypes, user}) { + const [showCBReviewModal, setShowCBReviewModal] = React.useState(false); + const handleModalToggle = useCallback(() => { + setShowCBReviewModal(!showCBReviewModal); + }, [showCBReviewModal]); + + const reviewsRef = createRef(); + + const handleUpdateReviews = useCallback(() => { + reviewsRef.current.handleClick(); + }, [reviewsRef]); + const urlPrefix = getEntityUrl(entity); return (
+ - - + + {!entity.deleted && - - - - - } + + + + + + + + + + + + }
+
Ratings
+
+ +
+
+ {reviewsCount ? + `${reviewsCount} review${reviewsCount > 1 ? 's' : ''}` : 'No reviews' + } +
+ + ); +} + +AverageRating.displayName = 'AverageRating'; +AverageRating.propTypes = { + averageRatings: PropTypes.number.isRequired, + reviewsCount: PropTypes.number.isRequired +}; + +export default AverageRating; diff --git a/src/client/components/pages/entities/cb-review.js b/src/client/components/pages/entities/cb-review.js new file mode 100644 index 0000000000..3225979e53 --- /dev/null +++ b/src/client/components/pages/entities/cb-review.js @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2022 Ansh Goyal + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as bootstrap from 'react-bootstrap'; +import {faPlus, faRotate} from '@fortawesome/free-solid-svg-icons'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import PropTypes from 'prop-types'; +import {Rating} from 'react-simple-star-rating'; +import React from 'react'; +import _ from 'lodash'; +import request from 'superagent'; + + +const {Button, Row} = bootstrap; + + +const REVIEW_CONTENT_PREVIEW_LENGTH = 75; + +function ReviewCard(props) { + if (!props?.reviewData || _.isEmpty(props.reviewData)) { + return null; + } + const {reviewData} = props; + const publishedDate = new Date(reviewData.published_on).toDateString(); + let reviewText = reviewData.text; + if (reviewText?.length > REVIEW_CONTENT_PREVIEW_LENGTH) { + reviewText = `${reviewText.substring(0, REVIEW_CONTENT_PREVIEW_LENGTH)}...`; + } + const reviewLink = `https://critiquebrainz.org/review/${reviewData.id}`; + return ( +
+
+ + + Review by: {reviewData.user.display_name} {publishedDate} + +
+ {reviewText} + View > +
+ ); +} + +class EntityReviews extends React.Component { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + this.state = { + reviews: props.entityReviews.reviews, + successfullyFetched: props.entityReviews.successfullyFetched + }; + this.entityType = props.entityType; + this.entityBBID = props.entityBBID; + this.handleModalToggle = props.handleModalToggle; + this.reviewsCount = props.entityReviews?.reviews?.average_rating?.count || 0; + } + + async handleClick() { + const data = await request.get(`/${this.entityType}/${this.entityBBID}/reviews`); + + this.setState({ + reviews: data.body.reviews, + successfullyFetched: data.body.successfullyFetched + }); + } + + render() { + let reviewContent; + const mapEntityType = { + Author: 'author', + EditionGroup: 'edition-group', + Series: 'series', + Work: 'literary-work' + }; + const cbEntityType = mapEntityType[this.entityType]; + const entityLink = `https://critiquebrainz.org/${cbEntityType}/${this.entityBBID}`; + if (this.state.reviews.reviews?.length && !_.isEmpty(this.state.reviews)) { + const {reviews: reviewsData} = this.state.reviews; + reviewContent = ( + + { + reviewsData.slice(0, 3).map((review) => ( + + )) + } + View all reviews > + + ); + } + else if (this.state.successfullyFetched) { + reviewContent = ( +
+

No reviews yet.

+ +
+ ); + } + else { + reviewContent = ( +
+

Could not fetch reviews.

+ +
+ ); + } + return ( + +

+ Reviews + + {this.reviewsCount ? + ` ${this.reviewsCount} review${this.reviewsCount > 1 ? 's' : ''}` : ' No reviews' + } + +

+ {reviewContent} +
+ ); + } +} + + +ReviewCard.displayName = 'ReviewCard'; +ReviewCard.propTypes = { + reviewData: PropTypes.shape({ + id: PropTypes.string.isRequired, + // eslint-disable-next-line camelcase + published_on: PropTypes.string.isRequired, + rating: PropTypes.number, + text: PropTypes.string, + user: PropTypes.shape({ + // eslint-disable-next-line camelcase + display_name: PropTypes.string.isRequired + }).isRequired + }).isRequired +}; + + +EntityReviews.displayName = 'EntityReviews'; +EntityReviews.propTypes = { + entityBBID: PropTypes.string.isRequired, + entityReviews: PropTypes.object.isRequired, + entityType: PropTypes.string.isRequired, + handleModalToggle: PropTypes.func.isRequired +}; + + +export default EntityReviews; diff --git a/src/client/components/pages/entities/cbReviewModal.tsx b/src/client/components/pages/entities/cbReviewModal.tsx new file mode 100644 index 0000000000..eaafcdca73 --- /dev/null +++ b/src/client/components/pages/entities/cbReviewModal.tsx @@ -0,0 +1,550 @@ +/* eslint-disable react/no-unused-state */ +/* + * Copyright (C) 2022 Ansh Goyal + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +import * as bootstrap from 'react-bootstrap'; +// eslint-disable-next-line import/no-internal-modules +import * as eng from '@cospired/i18n-iso-languages/langs/en.json'; +import * as iso from '@cospired/i18n-iso-languages'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +// eslint-disable-next-line import/named +import {IconProp} from '@fortawesome/fontawesome-svg-core'; +import {Rating} from 'react-simple-star-rating'; +import React from 'react'; +import ReactTooltip from 'react-tooltip'; +import {countWords} from '../../../helpers/utils'; +import {faInfoCircle} from '@fortawesome/free-solid-svg-icons'; +import request from 'superagent'; + + +const {Alert, Modal} = bootstrap; +iso.registerLocale(eng); + +export interface CBReviewModalProps { + entityBBID: string; + entityName: string; + entityType: string; + userId: number; + showModal: boolean; + handleModalToggle: () => void; + handleUpdateReviews: () => void; +} + +export interface CBReviewModalState { + acceptLicense: boolean; + alert: Record; + language: string; + rating: number; + reviewValidateAlert: string | null; + success: boolean; + textContent: string; + reviewID?: string; +} + +class CBReviewModal extends React.Component< + CBReviewModalProps, + CBReviewModalState +> { + constructor(props: CBReviewModalProps) { + super(props); + this.state = { + acceptLicense: false, + alert: { + message: '', + title: '', + type: '' + }, + language: 'en', + rating: 0, + reviewID: '', + reviewValidateAlert: null, + success: false, + textContent: '' + }; + } + + // eslint-disable-next-line react/sort-comp + readonly minTextLength = 25; + + readonly maxTextLength = 100000; + + private CBBaseUrl = 'https://critiquebrainz.org'; + + private MBBaseUrl = 'https://metabrainz.org'; + + // gets all iso-639-1 languages and codes for dropdown + private allLanguagesKeyValue = Object.entries(iso.getNames('en')); + + private CBInfoButton = ( + + + MetaBrainz project aimed at providing an open platform for music critics + and hosting Creative Commons licensed music reviews.

+ Your reviews will be independently visible on CritiqueBrainz and appear publicly + on your CritiqueBrainz profile. To view or delete your reviews, visit your + CritiqueBrainz profile.`} + > + +
+ +
+ ); + + handleError = (error: string | Error, title?: string): void => { + if (!error) { + return; + } + this.setState({ + alert: { + message: typeof error === 'object' ? error.message : error, + title: title || 'Error', + type: 'danger' + } + }); + }; + + getAccessToken = async () => { + try { + const response = await request + .post('/external-service/critiquebrainz/refresh'); + + if (response?.status === 200 && response?.body?.accessToken) { + return response.body.accessToken; + } + + return null; + } + catch (error) { + this.handleError(error, 'We could not submit your review'); + } + return null; + }; + + handleInputChange = ( + event: React.ChangeEvent + ) => { + const {target} = event; + const value = + target.type === 'checkbox' ? + (target as HTMLInputElement).checked : + target.value; + const {name} = target; + + this.setState({ + [name]: value + } as any); + }; + + handleTextInputChange = (event: React.ChangeEvent) => { + const {reviewValidateAlert} = this.state; + event.preventDefault(); + // remove excessive line breaks to match formatting to CritiqueBrainz + const input = event.target.value.replace(/\n\s*\n\s*\n/g, '\n'); + if (input.length <= this.maxTextLength) { + // cap input at maxTextLength + this.setState({textContent: input}); + } + + if (reviewValidateAlert && input.length >= this.minTextLength) { + // if warning was shown, rehide it when the input meets minTextLength + this.setState({ + reviewValidateAlert: null + }); + } + }; + + handleRatingsChange = (rate: number) => { + this.setState({ + rating: rate / 20 + }); + }; + + resetCBReviewForm = () => { + this.setState({ + acceptLicense: false, + rating: 0, + reviewValidateAlert: null, + success: true, + textContent: '' + }); + }; + + handleCloseModal = () => { + this.setState({ + acceptLicense: false, + alert: { + message: '', + title: '', + type: '' + }, + language: 'en', + rating: 0, + reviewValidateAlert: null, + textContent: '' + }); + this.props.handleModalToggle(); + }; + + handleSubmitToCB = async ( + event?: React.FormEvent + ): Promise => { + if (event) { + event.preventDefault(); + } + + const { + entityBBID, + entityType, + userId + } = this.props; + + const { + acceptLicense, + language, + rating, + textContent + } = this.state; + + if (textContent.length < this.minTextLength) { + this.setState({ + reviewValidateAlert: `Your review needs to be longer than ${this.minTextLength} characters.` + }); + return null; + } + + if (userId && + this.accessToken && + acceptLicense + ) { + let nonZeroRating: number; + if (rating !== 0) { + nonZeroRating = rating; + } + + const review = { + entityBBID, + entityType, + language, + rating: nonZeroRating, + textContent + }; + + try { + let result: any; + const response = await request.post(`/${entityType}/${entityBBID}/reviews`) + .set('Content-Type', 'application/json') + .send({ + accessToken: this.accessToken, + review + }); + + if (response.ok) { + result = response.body; + } + + if (result?.reviewID) { + this.setState({ + alert: { + message: 'Your review was submitted to CritiqueBrainz!', + title: 'Success', + type: 'success' + } + }); + this.setState({ + reviewID: result?.reviewID + }); + this.resetCBReviewForm(); + this.props.handleUpdateReviews(); + } + else { + this.setState({ + alert: { + message: result?.message, + title: 'Error submitting your review', + type: 'danger' + } + }); + } + } + catch (error) { + this.handleError( + error, + 'Error while submitting review to CritiqueBrainz' + ); + } + } + return null; + }; + + + getModalBody = (hasPermissions: boolean) => { + const { + acceptLicense, + alert, + language, + rating, + reviewID, + reviewValidateAlert, + success, + textContent + } = this.state; + + if (!hasPermissions) { + return ( +
+ Before you can submit reviews to{' '} + CritiqueBrainz, you must{' '} + connect to your CritiqueBrainz account from + BookBrainz. + {this.CBInfoButton} +
+
+ You can connect to your CritiqueBrainz account by visiting + the + + {' '} + external services page. + +
+ ); + } + + if (success) { + return ( +
+ Thanks for submitting your review for{' '} + {this.props.entityName}! +
+
+ You can access your CritiqueBrainz review by clicking{' '} + + {' '} + here. + +
+ ); + } + + return ( + <> + {alert?.message && ( + + {alert.title} +

{alert.message}

+
+ )} + + {reviewValidateAlert && ( + +

{reviewValidateAlert}

+
+ )} + + You are reviewing + + {` ${this.props.entityName} (${this.props.entityType}) `} + + for CritiqueBrainz.{' '} + + {this.CBInfoButton} +
+