From 6d48f8e3b023def9049dac9e30e229176af76715 Mon Sep 17 00:00:00 2001 From: Sam Arbid Date: Thu, 4 Jul 2024 16:27:12 +0200 Subject: [PATCH] comments: set maximum length for comments * UI: Add live character count validation to TimelineCommentEditor * UI: Connect config value through Redux store to make it available as we make config value configurable, this allowe the max limit to change dynamically as well * These changes depending on thins PR: https://github.com/inveniosoftware/react-invenio-forms/pull/244 * Disable submit button when comment exceed char count * Disable submit button when it's < 1 char * Show understandable error msg when exceeding limit --- .../js/invenio_requests/InvenioRequestsApp.js | 16 ++++-- .../js/invenio_requests/contrib/index.js | 7 +-- .../js/invenio_requests/request/Request.js | 7 ++- .../js/invenio_requests/requestsAppInit.js | 9 +++- .../semantic-ui/js/invenio_requests/store.js | 4 +- .../TimelineCommentEditor.js | 13 ++++- .../timelineCommentEditor/index.js | 3 ++ .../timelineCommentEditor/state/actions.js | 18 ++++++- .../timelineCommentEditor/state/reducer.js | 6 ++- .../timelineEvents/TimelineCommentEvent.js | 51 ++++++++++++++++--- invenio_requests/config.py | 4 ++ .../customizations/event_types.py | 5 +- .../invenio_requests/details/index.html | 2 + .../events/test_request_events_resources.py | 6 ++- 14 files changed, 124 insertions(+), 27 deletions(-) diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/InvenioRequestsApp.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/InvenioRequestsApp.js index 05d452ff..3dc1c549 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/InvenioRequestsApp.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/InvenioRequestsApp.js @@ -1,5 +1,6 @@ // This file is part of InvenioRequests // Copyright (C) 2022 CERN. +// Copyright (C) 2024 KTH Royal Institute of Technology. // // Invenio RDM Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -20,7 +21,13 @@ import { Provider } from "react-redux"; export class InvenioRequestsApp extends Component { constructor(props) { super(props); - const { requestsApi, requestEventsApi, request, defaultQueryParams } = this.props; + const { + request, + requestsApi, + requestEventsApi, + defaultQueryParams, + commentContentMaxLength, + } = this.props; const defaultRequestsApi = new InvenioRequestsAPI( new RequestLinksExtractor(request) ); @@ -32,8 +39,8 @@ export class InvenioRequestsApp extends Component { requestEventsApi: requestEventsApi || defaultRequestEventsApi, refreshIntervalMs: 5000, defaultQueryParams, + commentContentMaxLength, }; - this.store = configureStore(appConfig); } @@ -52,12 +59,13 @@ export class InvenioRequestsApp extends Component { InvenioRequestsApp.propTypes = { requestsApi: PropTypes.object, - requestEventsApi: PropTypes.object, overriddenCmps: PropTypes.object, + requestEventsApi: PropTypes.object, request: PropTypes.object.isRequired, - userAvatar: PropTypes.string.isRequired, defaultQueryParams: PropTypes.object, + userAvatar: PropTypes.string.isRequired, permissions: PropTypes.object.isRequired, + commentContentMaxLength: PropTypes.number.isRequired, }; InvenioRequestsApp.defaultProps = { diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/contrib/index.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/contrib/index.js index 8d04a812..185249d9 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/contrib/index.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/contrib/index.js @@ -1,5 +1,4 @@ import { - LabelStatusAccept, LabelStatusCancel, LabelStatusDecline, @@ -12,24 +11,20 @@ import { LabelTypeGuestAccess, LabelTypeUserAccess, LabelTypeCommunityManageRecord, - LabelTypeCommunitySubcommunity - + LabelTypeCommunitySubcommunity, } from "@js/invenio_requests/contrib"; import { - RequestAcceptButton, RequestCancelButton, RequestDeclineButton, RequestSubmitButton, } from "@js/invenio_requests/components/Buttons"; import { - RequestAcceptModalTrigger, RequestDeclineModalTrigger, RequestCancelModalTrigger, } from "@js/invenio_requests/components/ModalTriggers"; import { - AccessRequestIcon, CommunityInclusionIcon, CommunityInvitationIcon, diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/request/Request.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/request/Request.js index b3ee9176..37331fef 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/request/Request.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/request/Request.js @@ -19,7 +19,12 @@ export class Request extends Component { } render() { - const { request, updateRequestAfterAction, userAvatar, permissions } = this.props; + const { + request, + userAvatar, + permissions, + updateRequestAfterAction, + } = this.props; return ( diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/requestsAppInit.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/requestsAppInit.js index 45e8ba3c..83c3131f 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/requestsAppInit.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/requestsAppInit.js @@ -1,5 +1,6 @@ // This file is part of InvenioRequests // Copyright (C) 2022 CERN. +// Copyright (C) 2024 KTH Royal Institute of Technology. // // Invenio RDM Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -29,7 +30,7 @@ import { LabelTypeGuestAccess, LabelTypeUserAccess, LabelTypeCommunityManageRecord, - LabelTypeCommunitySubcommunity + LabelTypeCommunitySubcommunity, } from "./contrib"; import { AcceptStatus, @@ -53,7 +54,10 @@ const request = JSON.parse(requestDetailsDiv.dataset.record); const defaultQueryParams = JSON.parse(requestDetailsDiv.dataset.defaultQueryConfig); const userAvatar = JSON.parse(requestDetailsDiv.dataset.userAvatar); const permissions = JSON.parse(requestDetailsDiv.dataset.permissions); - +const commentContentMaxLength = parseInt( + requestDetailsDiv.dataset.commentContentMaxLength, + 10 +); const defaultComponents = { ...defaultContribComponents, "TimelineEvent.layout.unknown": TimelineUnknownEvent, @@ -97,6 +101,7 @@ ReactDOM.render( overriddenCmps={{ ...defaultComponents, ...overriddenComponents }} userAvatar={userAvatar} permissions={permissions} + commentContentMaxLength={commentContentMaxLength} />, requestDetailsDiv ); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/store.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/store.js index b4637d49..9d83c5be 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/store.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/store.js @@ -1,5 +1,6 @@ // This file is part of InvenioRequests // Copyright (C) 2022 CERN. +// Copyright (C) 2024 KTH Royal Institute of Technology. // // Invenio RDM Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -16,12 +17,13 @@ const composeEnhancers = composeWithDevTools({ export function configureStore(config) { const { size } = config.defaultQueryParams; + const { commentContentMaxLength } = config; return createStore( createReducers(), // config object will be available in the actions, { - timeline: { ...initialTimeLineState, size }, + timeline: { ...initialTimeLineState, size, commentContentMaxLength }, }, composeEnhancers(applyMiddleware(thunk.withExtraArgument(config))) ); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/TimelineCommentEditor.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/TimelineCommentEditor.js index b505c559..04c0abed 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/TimelineCommentEditor.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/TimelineCommentEditor.js @@ -1,5 +1,6 @@ // This file is part of InvenioRequests // Copyright (C) 2022 CERN. +// Copyright (C) 2024 KTH Royal Institute of Technology. // // Invenio RDM Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -19,7 +20,10 @@ const TimelineCommentEditor = ({ error, submitComment, userAvatar, + charCount, + commentContentMaxLength, }) => { + const isSubmitDisabled = () => isLoading || (charCount === 0) || (charCount >= commentContentMaxLength) return (
{error && {error}} @@ -30,8 +34,8 @@ const TimelineCommentEditor = ({ /> { + inputValue={commentContent} + onEditorChange={(event, editor) => { setCommentContent(editor.getContent()); }} minHeight={150} @@ -43,6 +47,7 @@ const TimelineCommentEditor = ({ icon="send" size="medium" content={i18next.t("Comment")} + disabled={isSubmitDisabled()} loading={isLoading} onClick={() => submitComment(commentContent, "html")} /> @@ -58,6 +63,8 @@ TimelineCommentEditor.propTypes = { error: PropTypes.string, submitComment: PropTypes.func.isRequired, userAvatar: PropTypes.string, + charCount: PropTypes.number, + commentContentMaxLength: PropTypes.number, }; TimelineCommentEditor.defaultProps = { @@ -65,6 +72,8 @@ TimelineCommentEditor.defaultProps = { isLoading: false, error: "", userAvatar: "", + charCount: 0, + commentContentMaxLength: 0, }; export default TimelineCommentEditor; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/index.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/index.js index c44f3225..ae969b61 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/index.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/index.js @@ -1,5 +1,6 @@ // This file is part of InvenioRequests // Copyright (C) 2022 CERN. +// Copyright (C) 2024 KTH Royal Institute of Technology. // // Invenio RDM Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -17,6 +18,8 @@ const mapStateToProps = (state) => ({ isLoading: state.timelineCommentEditor.isLoading, error: state.timelineCommentEditor.error, commentContent: state.timelineCommentEditor.commentContent, + charCount: state.timelineCommentEditor.charCount, + commentContentMaxLength: state.timeline.commentContentMaxLength, }); export const TimelineCommentEditor = connect( diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/actions.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/actions.js index 2009386f..612ba84d 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/actions.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/actions.js @@ -1,5 +1,6 @@ // This file is part of InvenioRequests // Copyright (C) 2022 CERN. +// Copyright (C) 2024 KTH Royal Institute of Technology. // // Invenio RDM Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. @@ -12,11 +13,14 @@ import { SUCCESS as TIMELINE_SUCCESS, } from "../../timeline/state/actions"; import _cloneDeep from "lodash/cloneDeep"; +import { i18next } from "@translations/invenio_requests/i18next"; export const IS_LOADING = "eventEditor/IS_LOADING"; export const HAS_ERROR = "eventEditor/HAS_ERROR"; export const SUCCESS = "eventEditor/SUCCESS"; export const SETTING_CONTENT = "eventEditor/SETTING_CONTENT"; +export const SET_CHAR_COUNT = "eventEditor/SET_CHAR_COUNT"; +export const CLEAR_ERROR = "eventEditor/CLEAR_ERROR"; export const setEventContent = (content) => { return async (dispatch, getState, config) => { @@ -24,6 +28,19 @@ export const setEventContent = (content) => { type: SETTING_CONTENT, payload: content, }); + + const commentContentMaxLength = config.commentContentMaxLength; + const charCount = content.length; + const errorMessage = i18next.t("Character count exceeds the maximum allowed limit."); + + const validateContentLength = (charCount, maxLength, errorMessage) => { + return charCount >= maxLength ? errorMessage : null; + }; + + const error = validateContentLength(charCount, commentContentMaxLength, errorMessage); + + dispatch({ type: SET_CHAR_COUNT, payload: charCount }); + dispatch({ type: HAS_ERROR, payload: error }); }; }; @@ -44,7 +61,6 @@ export const submitComment = (content, format) => { That includes the pagination logic e.g. changing pages if the current page size is exceeded by a new comment. */ const response = await config.requestsApi.submitComment(payload); - const currentPage = timelineState.page; const currentSize = timelineState.size; const currentCommentsLength = timelineState.data.hits.hits.length; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/reducer.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/reducer.js index 9075cd9b..50d7cae9 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/reducer.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/reducer.js @@ -1,15 +1,17 @@ // This file is part of InvenioRequests // Copyright (C) 2022 CERN. +// Copyright (C) 2024 KTH Royal Institute of Technology. // // Invenio RDM Records is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. -import { IS_LOADING, HAS_ERROR, SUCCESS, SETTING_CONTENT } from "./actions"; +import { IS_LOADING, HAS_ERROR, SUCCESS, SETTING_CONTENT, SET_CHAR_COUNT } from "./actions"; const initial_state = { error: null, isLoading: false, commentContent: "", + charCount: 0, }; export const commentEditorReducer = (state = initial_state, action) => { @@ -27,6 +29,8 @@ export const commentEditorReducer = (state = initial_state, action) => { error: null, commentContent: "", }; + case SET_CHAR_COUNT: + return { ...state, charCount: action.payload }; default: return state; } diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/TimelineCommentEvent.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/TimelineCommentEvent.js index fd922ce1..56cc8ed7 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/TimelineCommentEvent.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/TimelineCommentEvent.js @@ -24,10 +24,47 @@ class TimelineCommentEvent extends Component { const { event } = props; this.state = { - commentContent: event?.payload?.content, + commentContent: event?.payload?.content || '', + inputError: null, + isDisabled: false, + commentContentMaxLength: 0, + charCount: event?.payload?.content?.length || 0, }; } + componentDidMount() { + const requestDetailsDiv = document.getElementById("request-detail"); + if (requestDetailsDiv) { + const commentContentMaxLength = parseInt( + requestDetailsDiv.dataset.commentContentMaxLength, + 10 + ); + this.setState({ commentContentMaxLength }); + } + } + + validateContentLength = (charCount, maxLength) => { + const errorMessage = i18next.t("Character count exceeds the maximum allowed limit."); + return { + isDisabled: (charCount === 0) || (charCount >= maxLength), + inputError: charCount >= maxLength ? errorMessage : null, + }; + }; + + handleEditorChange = (event, editor) => { + const content = editor.getContent(); + const charCount = content.length; + const { commentContentMaxLength } = this.state; + + const { isDisabled, inputError } = this.validateContentLength(charCount, commentContentMaxLength); + + this.setState({ + commentContent: content, + charCount, + isDisabled, + inputError, + }); + }; eventToType = ({ type, payload }) => { switch (type) { case "L": @@ -49,7 +86,7 @@ class TimelineCommentEvent extends Component { deleteComment, toggleEditMode, } = this.props; - const { commentContent } = this.state; + const { commentContent, inputError } = this.state; const commentHasBeenEdited = event?.revision_id > 1 && event?.payload; @@ -109,14 +146,13 @@ class TimelineCommentEvent extends Component { - {error && } + {(error || inputError) && } {isEditing ? ( { - this.setState({ commentContent: editor.getContent() }); - }} + initialValue={event?.payload?.content} + inputValue={commentContent} + onEditorChange={this.handleEditorChange} minHeight={150} /> ) : ( @@ -132,6 +168,7 @@ class TimelineCommentEvent extends Component { updateComment(commentContent, "html")} loading={isLoading} + disabled={isLoading || this.state.isDisabled} /> )} diff --git a/invenio_requests/config.py b/invenio_requests/config.py index 88cb6891..666b44b5 100644 --- a/invenio_requests/config.py +++ b/invenio_requests/config.py @@ -2,6 +2,7 @@ # # Copyright (C) 2021 CERN. # Copyright (C) 2021 - 2022 TU Wien. +# Copyright (C) 2024 KTH Royal Institute of Technology. # # Invenio-Requests is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -117,3 +118,6 @@ "is_open": {"facet": facets.is_open, "ui": {"field": "is_open"}}, } """Available facets defined for this module.""" + +REQUESTS_COMMENT_CONTENT_MAX_LENGTH = 25000 +"""Maximum length of a comment content.""" diff --git a/invenio_requests/customizations/event_types.py b/invenio_requests/customizations/event_types.py index f26158de..41a5c129 100644 --- a/invenio_requests/customizations/event_types.py +++ b/invenio_requests/customizations/event_types.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # # Copyright (C) 2022-2024 CERN. +# Copyright (C) 2024 KTH Royal Institute of Technology. # # Invenio-Requests is free software; you can redistribute it and/or modify # it under the terms of the MIT License; see LICENSE file for more details. @@ -10,6 +11,7 @@ import inspect import marshmallow as ma +from flask import current_app from marshmallow import RAISE, fields, validate from marshmallow_utils import fields as utils_fields @@ -140,9 +142,10 @@ def payload_schema(): # we need to import here because of circular imports from invenio_requests.records.api import RequestEventFormat + max = current_app.config.get("REQUESTS_COMMENT_CONTENT_MAX_LENGTH", 25000) return dict( content=utils_fields.SanitizedHTML( - required=True, validate=validate.Length(min=1) + required=True, validate=validate.Length(min=1, max=max) ), format=fields.Str( validate=validate.OneOf(choices=[e.value for e in RequestEventFormat]), diff --git a/invenio_requests/templates/semantic-ui/invenio_requests/details/index.html b/invenio_requests/templates/semantic-ui/invenio_requests/details/index.html index e81b3415..c4a5f35a 100644 --- a/invenio_requests/templates/semantic-ui/invenio_requests/details/index.html +++ b/invenio_requests/templates/semantic-ui/invenio_requests/details/index.html @@ -2,6 +2,7 @@ This file is part of Invenio. Copyright (C) 2016-2020 CERN. + Copyright (C) 2024 KTH Royal Institute of Technology. Invenio is free software; you can redistribute it and/or modify it under the terms of the MIT License; see LICENSE file for more details. @@ -49,6 +50,7 @@

data-default-query-config='{{ dict(size=config["REQUESTS_TIMELINE_PAGE_SIZE"]) | tojson }}' data-user-avatar='{{ user_avatar | tojson }}' data-permissions='{{ permissions | tojson }}' + data-comment-content-max-length='{{ config["REQUESTS_COMMENT_CONTENT_MAX_LENGTH"] }}' >{# react app root #}
diff --git a/tests/resources/events/test_request_events_resources.py b/tests/resources/events/test_request_events_resources.py index 11ea369b..093c73d3 100644 --- a/tests/resources/events/test_request_events_resources.py +++ b/tests/resources/events/test_request_events_resources.py @@ -2,6 +2,7 @@ # # Copyright (C) 2021 CERN. # Copyright (C) 2021 Northwestern University. +# Copyright (C) 2024 KTH Royal Institute of Technology. # # Invenio-Requests is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more @@ -197,7 +198,10 @@ def test_empty_comment( expected_json = { **expected_json, "errors": [ - {"field": "payload.content", "messages": ["Shorter than minimum length 1."]} + { + "field": "payload.content", + "messages": ["Length must be between 1 and 25000."], + } ], } assert expected_json == response.json