+`;
diff --git a/adhocracy4/ratings/static/ratings/rating_api.js b/adhocracy4/ratings/static/ratings/rating_api.js
new file mode 100644
index 000000000..3996610c1
--- /dev/null
+++ b/adhocracy4/ratings/static/ratings/rating_api.js
@@ -0,0 +1,96 @@
+import api from '../../../static/api'
+
+/**
+ * @param {number} number - the rating value, can be -1, 0 or 1
+ * @param {string} objectId - the object to be rated
+ * @param {string} contentTypeId - the content type id of the object
+
+ * @returns {Promise} - an array with the rating data (number of negative
+ * and positive ratings) and the user rating data (value and id and userHasRating)
+ * and the complete response data from the API.
+ */
+export async function createRating (number, objectId, contentTypeId) {
+ try {
+ const data = await api.rating.add({
+ urlReplaces: {
+ objectPk: objectId,
+ contentTypeId
+ },
+ value: number
+ })
+
+ return [{
+ positive: data.meta_info.positive_ratings_on_same_object,
+ negative: data.meta_info.negative_ratings_on_same_object
+ }, {
+ userRating: data.meta_info.user_rating_on_same_object_value,
+ userHasRating: true,
+ userRatingId: data.id
+ }, data]
+ } catch (error) {
+ if (error.status === 400 &&
+ error.responseJSON.length === 1 &&
+ Number.isInteger(parseInt(error.responseJSON[0]))
+ ) {
+ const userRatingId = parseInt(error.responseJSON[0])
+ const [ratings, userState, data] = (await modifyRating(number, userRatingId))[0]
+ return [
+ ratings,
+ {
+ ...userState,
+ userHasRating: true,
+ userRatingId
+ },
+ data
+ ]
+ }
+ }
+}
+
+/**
+ * @param {number} number - the rating value, can be -1, 0 or 1
+ * @param {number} id - the id of the users rating
+ * @param {string} objectId - the object to be rated
+ * @param {string} contentTypeId - the content type id of the object
+
+ * @returns {Promise} - an array with the rating data (number of negative
+ * and positive ratings) and the user rating data (value)
+ * and the complete response data from the API.
+ */
+export async function modifyRating (number, id, objectId, contentTypeId) {
+ const data = await api.rating.change({
+ urlReplaces: {
+ objectPk: objectId,
+ contentTypeId
+ },
+ value: number
+ }, id)
+ return [
+ {
+ positive: data.meta_info.positive_ratings_on_same_object,
+ negative: data.meta_info.negative_ratings_on_same_object
+ },
+ { userRating: data.meta_info.user_rating_on_same_object_value },
+ data
+ ]
+}
+
+/**
+ * Helper function to easily create OR modify a rating
+ *
+ * @param {number} number - the rating value, can be -1, 0 or 1
+ * @param {string} objectId - the object to be rated
+ * @param {string} contentTypeId - the content type id of the object
+ * @param {number} [id] - the id of the users rating
+
+ * @returns {Promise} - an array with the rating data (number of negative
+ * and positive ratings) and the user rating data (value)
+ * and the complete response data from the API.
+ */
+export async function createOrModifyRating (number, objectId, contentTypeId, id) {
+ if (id) {
+ return await modifyRating(number, id, objectId, contentTypeId)
+ } else {
+ return await createRating(number, objectId, contentTypeId)
+ }
+}
diff --git a/adhocracy4/ratings/static/ratings/react_ratings.jsx b/adhocracy4/ratings/static/ratings/react_ratings.jsx
index c09873a84..980a650f6 100644
--- a/adhocracy4/ratings/static/ratings/react_ratings.jsx
+++ b/adhocracy4/ratings/static/ratings/react_ratings.jsx
@@ -1,158 +1,6 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
-import django from 'django'
-
-import api from '../../../static/api'
-import config from '../../../static/config'
-
-const translations = {
- upvote: django.gettext('Click to vote up'),
- downvote: django.gettext('Click to vote down'),
- likes: django.gettext('Likes'),
- dislikes: django.gettext('Dislikes')
-}
-
-class RatingBox extends React.Component {
- constructor (props) {
- super(props)
-
- this.state = {
- positiveRatings: this.props.positiveRatings,
- negativeRatings: this.props.negativeRatings,
- userHasRating: this.props.userRating !== null,
- userRating: this.props.userRating,
- userRatingId: this.props.userRatingId
- }
- }
-
- handleRatingCreate (number) {
- api.rating.add({
- urlReplaces: {
- objectPk: this.props.objectId,
- contentTypeId: this.props.contentType
- },
- value: number
- })
- .done(function (data) {
- this.setState({
- positiveRatings: data.meta_info.positive_ratings_on_same_object,
- negativeRatings: data.meta_info.negative_ratings_on_same_object,
- userRating: data.meta_info.user_rating_on_same_object_value,
- userHasRating: true,
- userRatingId: data.id
- })
- }.bind(this))
- .fail(function (jqXhr) {
- if (jqXhr.status === 400 &&
- jqXhr.responseJSON.length === 1 &&
- Number.isInteger(parseInt(jqXhr.responseJSON[0]))) {
- this.setState({
- userHasRating: true,
- userRatingId: jqXhr.responseJSON[0]
- })
- this.handleRatingModify(number, this.state.userRatingId)
- }
- }.bind(this))
- }
-
- handleRatingModify (number, id) {
- api.rating.change({
- urlReplaces: {
- objectPk: this.props.objectId,
- contentTypeId: this.props.contentType
- },
- value: number
- }, id)
- .done(function (data) {
- this.setState({
- positiveRatings: data.meta_info.positive_ratings_on_same_object,
- negativeRatings: data.meta_info.negative_ratings_on_same_object,
- userRating: data.meta_info.user_rating_on_same_object_value
- })
- }.bind(this))
- }
-
- ratingUp (e) {
- e.preventDefault()
- if (this.props.authenticatedAs === null) {
- window.location.href = config.getLoginUrl()
- return
- }
- if (this.props.isReadOnly) {
- return
- }
- if (this.state.userHasRating) {
- let number
- if (this.state.userRating === 1) {
- number = 0
- } else {
- number = 1
- }
- this.handleRatingModify(number, this.state.userRatingId)
- } else {
- this.handleRatingCreate(1)
- }
- }
-
- ratingDown (e) {
- e.preventDefault()
- if (this.props.authenticatedAs === null) {
- window.location.href = config.getLoginUrl()
- return
- }
- if (this.props.isReadOnly) {
- return
- }
- if (this.state.userHasRating) {
- let number
- if (this.state.userRating === -1) {
- number = 0
- } else {
- number = -1
- }
- this.handleRatingModify(number, this.state.userRatingId)
- } else {
- this.handleRatingCreate(-1)
- }
- }
-
- render () {
- const getRatingClasses = ratingType => {
- const valueForRatingType = ratingType === 'up' ? 1 : -1
- const cssClasses = this.state.userRating === valueForRatingType
- ? 'rating-button rating-' + ratingType + ' is-selected'
- : 'rating-button rating-' + ratingType
- return cssClasses
- }
-
- return (
-
-
-
-
- )
- }
-}
-
-module.exports.RatingBox = RatingBox
+import RatingBox from './RatingBox'
module.exports.renderRatings = function (el) {
const props = JSON.parse(el.getAttribute('data-attributes'))
diff --git a/adhocracy4/ratings/templatetags/react_ratings.py b/adhocracy4/ratings/templatetags/react_ratings.py
index 08ef8c8a5..047d0a550 100644
--- a/adhocracy4/ratings/templatetags/react_ratings.py
+++ b/adhocracy4/ratings/templatetags/react_ratings.py
@@ -33,7 +33,7 @@ def react_ratings(context, obj):
user_rating_id = user_rating.pk
else:
user_rating_value = None
- user_rating_id = -1
+ user_rating_id = None
attributes = {
"contentType": contenttype.pk,
diff --git a/changelog/8529.md b/changelog/8529.md
new file mode 100644
index 000000000..c368c0c74
--- /dev/null
+++ b/changelog/8529.md
@@ -0,0 +1,9 @@
+### Changed
+- Ratings are now functional components
+- RatingBox has been split into RatingBox and RatingButton
+- redirect when logged out now goes back to specific comment if the object was a comment
+
+### Added
+- RatingBox takes an optional render function to customize rendering
+- added `jest-dom` to tests, allowing for nicer matchers like `toBeInTheDocument`
+- added a rating_api file to allow for more modular api calls
diff --git a/docs/react_ratings.md b/docs/react_ratings.md
new file mode 100644
index 000000000..54a800abe
--- /dev/null
+++ b/docs/react_ratings.md
@@ -0,0 +1,129 @@
+# Ratings Component Documentation
+This document is there to provide a documentation about the slightly reworked
+ratings component which allows for more customization in other projects
+
+
+## Code List
+
+* **RatingBox**:
+ Main component for displaying Ratings. This is used both in customized rendering
+ as well as default rendering (= the rendering that is used in adhocracy4). It
+ is also responsible for all the event handler logic.
+* **RatingButton**:
+ Button component responsible for displaying accessible rating options and
+ passing the selected rating to the parent component.
+* **rating_api**:
+ API helper for modifying or creating ratings. Functions return an appropriate
+ state.
+
+Patterns used:
+- [Render prop](https://react.dev/reference/react/Children#calling-a-render-prop-to-customize-rendering)
+
+## RatingBox
+
+The `RatingBox` component is the main component for ratings. You use this if you
+just want the default a4 `RatingBox` but also if you want to customize it.
+
+### Props
+
+* **positiveRatings**: Number of initial positive ratings
+* **negativeRatings**: Number of initial negative ratings
+* **userHasRating**: Boolean if the user initially has already rated
+* **userRating**: Value of the initial user rating (-1, null or 1)
+* **userRatingId**: Id of the user rating object
+* **isReadOnly**: Boolean if the rating box should be read only
+* **contentType**: ID of the content type to be rated
+* **objectId**: ID of the object to be rated
+* **authenticatedAs**: Currently authenticated user or null
+* **isComment**: Boolean if the rating is for a comment
+* **render**: This prop is used to provide custom rendering
+
+### Custom Rendering
+If your project needs something that's different than what a4 provides you with
+styling-wise, you can use the `render` prop to provide your own rendering. To do
+so, you pass a function to the `render` prop. The function will receive the
+following props:
+
+* **ratings**: An object with the following keys, representing the number of rates
+ * **positive**: Number of positive ratings
+ * **negative**: Number of negative ratings
+* **userRatingData**: An object with the following keys, representing the user state
+ * **userHasRating**: Boolean if the user has already rated
+ * **userRating**: Value of the user rating (-1, null or 1)
+ * **userRatingId**: Id of the user rating object or null
+* **isReadOnly**: Boolean if the rating box should be read only
+* **clickHandler**: Add this function to the element that should trigger the rating
+ and pass the rating value to it (-1, 0 or 1)
+
+Here an example if you would want to set the number of ratings in parentheses instead:
+
+```jsx
+ (
+ <>
+
+
+ >
+)} />
+```
+
+## RatingButton
+
+The `RatingButton` component is a button component that is used to display
+accessible rating options and pass the selected rating to the provided click handler.
+It is also responsible for redirecting if the user is not authenticated.
+
+### Props
+
+* **rating**: The value of the rating for this button (-1 or 1)
+* **active**: Whether the user has selected this rating
+* **onClick**: Function to be called when a rating is selected
+* **authenticatedAs**: Currently authenticated user or null
+* **isReadOnly**: Boolean if the rating button should be read only
+* **children**: The content you want to render inside the button
+* **id**: The id of a **comment** to be rated
+* **isComment**: Boolean if the rating is for a comment
+
+The last two props are only used to redirect to a specific comment if the user
+is not authenticated.
+
+### Styling
+You can style the rating buttons with the following classes:
+
+* **rating-button**: The base class for the rating button
+* **rating-down**: The class for the down rating button
+* **rating-up**: The class for the up rating button
+* **is-selected**: The class for the selected rating button
+* **.rating-button[disabled]**: The selector for a disabled rating button
+ * The button will only be disabled if **both** of the following are true:
+ * The user is not authenticated
+ * The rating is read only
+
+## RatingApi
+
+This is a set of functions made to help communicating with the ratings api. There
+is also a convenience function called createOrModifyRating that allows you to
+create or modify a rating in one go.
+
+### createRating
+A function that creates a rating, given the following parameters:
+
+* **number**: Value of the rating (-1, 0 or 1)
+* **objectId**: ID of the object to be rated
+* **contentType**: ID of the content type to be rated
+
+### modifyRating
+A function that modifies a rating, given the following parameters:
+
+* **number**: Value of the rating (-1, 0 or 1)
+* **id**: ID of the rating to be modified
+* **objectId**: ID of the object to be rated
+* **contentType**: ID of the content type to be rated
+
+### createOrModifyRating
+A convenience function that allows you to create or modify a rating in one go,
+given the following parameters:
+
+* **number**: Value of the rating (-1, 0 or 1)
+* **objectId**: ID of the object to be rated
+* **contentType**: ID of the content type to be rated
+* **id (optional)**: ID of the rating to be modified
diff --git a/jest.config.js b/jest.config.js
index fc57f69fc..e099a3133 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -54,7 +54,7 @@ const config = {
esModules +
').+\\.(js|jsx|mjs|cjs|ts|tsx)$'
],
- setupFiles: ['/setupTests.js'],
+ setupFilesAfterEnv: ['/setupTests.js'],
coverageReporters: ['lcov']
}
diff --git a/setupTests.js b/setupTests.js
index 47fc61ff2..eb5f4b941 100644
--- a/setupTests.js
+++ b/setupTests.js
@@ -1,3 +1,5 @@
+import '@testing-library/jest-dom'
+
if (typeof window.URL.createObjectURL === 'undefined') {
window.URL.createObjectURL = () => {}
}