diff --git a/CHANGES.rst b/CHANGES.rst index 78d4e488..5fefa6db 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,15 @@ Changes ======= +Version v12.3.0 (released 2026-02-19) + +- feat(timeline): file upload for comments +- feat(timeline): deep link for replies +- feat(timeline): make messages collapsible +- fix(comment-editor): disable comment button when message is empty +- fix(timeline): defer dataset extraction +- fix(timeline): resize detection for comment body + version v12.2.1 (released 2026-02-17) - fix(events): add back support for #commentevent anchor diff --git a/invenio_requests/__init__.py b/invenio_requests/__init__.py index 196ac5c3..7b487a06 100644 --- a/invenio_requests/__init__.py +++ b/invenio_requests/__init__.py @@ -14,18 +14,20 @@ from .proxies import ( current_event_type_registry, current_events_service, + current_request_files_service, current_request_type_registry, current_requests, current_requests_resource, current_requests_service, ) -__version__ = "12.2.1" +__version__ = "12.3.0" __all__ = ( "__version__", "current_event_type_registry", "current_events_service", + "current_request_files_service", "current_request_type_registry", "current_requests_resource", "current_requests_service", diff --git a/invenio_requests/alembic/1763728177_create_request_files_table.py b/invenio_requests/alembic/1763728177_create_request_files_table.py new file mode 100644 index 00000000..a28ba980 --- /dev/null +++ b/invenio_requests/alembic/1763728177_create_request_files_table.py @@ -0,0 +1,119 @@ +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Create request files table.""" + +import sqlalchemy as sa +import sqlalchemy_utils +from alembic import op +from sqlalchemy.dialects import mysql, postgresql + +# revision identifiers, used by Alembic. +revision = "1763728177" +down_revision = "1759321170" +branch_labels = () +depends_on = "8ae99b034410" + + +def upgrade(): + """Upgrade database.""" + op.create_table( + "request_files", + sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=False), + sa.Column( + "json", + sa.JSON() + .with_variant(sqlalchemy_utils.types.json.JSONType(), "mysql") + .with_variant( + postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), "postgresql" + ) + .with_variant(sqlalchemy_utils.types.json.JSONType(), "sqlite"), + nullable=True, + ), + sa.Column("version_id", sa.Integer(), nullable=False), + sa.Column( + "created", + sa.DateTime().with_variant(mysql.DATETIME(fsp=6), "mysql"), + nullable=False, + ), + sa.Column( + "updated", + sa.DateTime().with_variant(mysql.DATETIME(fsp=6), "mysql"), + nullable=False, + ), + sa.Column( + "key", + sa.Text().with_variant(mysql.VARCHAR(length=255), "mysql"), + nullable=False, + ), + sa.Column("record_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=False), + sa.Column( + "object_version_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True + ), + sa.ForeignKeyConstraint( + ["object_version_id"], + ["files_object.version_id"], + name=op.f("fk_request_files_object_version_id_files_object"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["record_id"], + ["request_metadata.id"], + name=op.f("fk_request_files_record_id_request_metadata"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_request_files")), + ) + op.create_index( + op.f("ix_request_files_object_version_id"), + "request_files", + ["object_version_id"], + unique=False, + ) + op.create_index( + op.f("ix_request_files_record_id"), "request_files", ["record_id"], unique=False + ) + op.create_index( + "uidx_request_files_record_id_key", + "request_files", + ["record_id", "key"], + unique=True, + ) + op.add_column( + "request_metadata", + sa.Column("bucket_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True), + ) + op.create_index( + op.f("ix_request_metadata_bucket_id"), + "request_metadata", + ["bucket_id"], + unique=False, + ) + op.create_foreign_key( + op.f("fk_request_metadata_bucket_id_files_bucket"), + "request_metadata", + "files_bucket", + ["bucket_id"], + ["id"], + ondelete="RESTRICT", + ) + + +def downgrade(): + """Downgrade database.""" + op.drop_index(op.f("ix_request_metadata_bucket_id"), table_name="request_metadata") + op.drop_constraint( + op.f("fk_request_metadata_bucket_id_files_bucket"), + "request_metadata", + type_="foreignkey", + ) + op.drop_column("request_metadata", "bucket_id") + op.drop_index("uidx_request_files_record_id_key", table_name="request_files") + op.drop_index(op.f("ix_request_files_record_id"), table_name="request_files") + op.drop_index( + op.f("ix_request_files_object_version_id"), table_name="request_files" + ) + op.drop_table("request_files") 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 769f060d..39691a0e 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/InvenioRequestsApp.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/InvenioRequestsApp.js @@ -17,6 +17,7 @@ import PropTypes from "prop-types"; import { configureStore } from "./store"; import { OverridableContext } from "react-overridable"; import { Provider } from "react-redux"; +import { DatasetContext } from "./data"; export class InvenioRequestsApp extends Component { constructor(props) { @@ -24,10 +25,9 @@ export class InvenioRequestsApp extends Component { const { requestsApi, requestEventsApi, - request, - defaultQueryParams, - defaultReplyQueryParams, + dataset: { request, defaultQueryParams, defaultReplyQueryParams }, } = this.props; + const defaultRequestsApi = new InvenioRequestsAPI( new RequestLinksExtractor(request) ); @@ -46,12 +46,19 @@ export class InvenioRequestsApp extends Component { } render() { - const { overriddenCmps, userAvatar, permissions, config } = this.props; + const { overriddenCmps, dataset } = this.props; + const { userAvatar, permissions, config } = dataset; return ( - + + + ); @@ -59,24 +66,14 @@ export class InvenioRequestsApp extends Component { } InvenioRequestsApp.propTypes = { + dataset: PropTypes.object.isRequired, requestsApi: PropTypes.object, requestEventsApi: PropTypes.object, overriddenCmps: PropTypes.object, - request: PropTypes.object.isRequired, - userAvatar: PropTypes.string.isRequired, - defaultQueryParams: PropTypes.object, - defaultReplyQueryParams: PropTypes.object, - permissions: PropTypes.object.isRequired, - config: PropTypes.object, }; InvenioRequestsApp.defaultProps = { overriddenCmps: {}, requestsApi: null, requestEventsApi: null, - defaultQueryParams: { size: 15 }, - defaultReplyQueryParams: { size: 5 }, - config: { - allowGroupReviewers: false, - }, }; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestApi.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestApi.js index e81e65ba..184dfe14 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestApi.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestApi.js @@ -133,13 +133,14 @@ export class InvenioRequestsAPI { }); }; - performAction = async (action, commentContent = null) => { + performAction = async (action, commentContent = null, files = []) => { let payload = {}; if (!_isEmpty(commentContent)) { payload = { payload: { content: commentContent, format: "html", + files: files, }, }; } diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestEventsApi.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestEventsApi.js index 263bd626..deb12855 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestEventsApi.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestEventsApi.js @@ -46,6 +46,17 @@ export class RequestEventsLinksExtractor { return this.#links.replies; } + get focusedRepliesUrl() { + if (!this.#links.replies_focused) { + throw TypeError( + i18next.t("{{link_name}} link missing from resource.", { + link_name: "Focused replies", + }) + ); + } + return this.#links.replies_focused; + } + get replyUrl() { if (!this.#links.reply) { throw TypeError( @@ -86,6 +97,16 @@ export class InvenioRequestEventsApi { }); }; + getRepliesFocused = async (focusReplyEventId, params) => { + return await http.get(this.#links.focusedRepliesUrl, { + params: { + expand: 1, + focus_event_id: focusReplyEventId, + ...params, + }, + }); + }; + submitReply = async (payload) => { return await http.post(this.#links.replyUrl, payload, { params: { expand: 1 }, diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestFilesApi.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestFilesApi.js new file mode 100644 index 00000000..e317b841 --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestFilesApi.js @@ -0,0 +1,47 @@ +// This file is part of InvenioRequests +// Copyright (C) 2025 CERN. +// +// 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 { http } from "react-invenio-forms"; + +export class InvenioRequestFilesApi { + baseUrl = "/api/requests"; + + /** + * Upload a file linked to a request. + * + * @param {string} requestId - Request ID + * @param {string} filename - Original filename + * @param {object} payload - File + * @param {object} options - Custom options + */ + async uploadFile(requestId, filename, payload, options) { + options = options || {}; + const headers = { + "Content-Type": "application/octet-stream", + }; + return http.put(`${this.baseUrl}/${requestId}/files/upload/${filename}`, payload, { + headers: headers, + ...options, + }); + } + + /** + * Delete a file linked to a request. + * + * @param {string} requestId - Request ID + * @param {string} fileKey - Unique filename (key) + * @param {object} options - Custom options + */ + async deleteFile(requestId, fileKey, options) { + options = options || {}; + const headers = { + "Content-Type": "application/octet-stream", + }; + return http.delete(`${this.baseUrl}/${requestId}/files/${fileKey}`, { + headers: headers, + ...options, + }); + } +} diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/api/serializers.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/api/serializers.js index c26317b5..c98cff35 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/api/serializers.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/api/serializers.js @@ -3,10 +3,13 @@ // // 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. -export const payloadSerializer = (content, format) => ({ +export const payloadSerializer = (content, format, files) => ({ payload: { content, format, + files: files.map((file) => ({ + file_id: file.file_id, + })), }, }); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/FakeInput.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/FakeInput.js index fbdd1bd0..75fc3873 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/FakeInput.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/FakeInput.js @@ -34,7 +34,7 @@ FakeInput.propTypes = { placeholder: PropTypes.string.isRequired, userAvatar: PropTypes.string, onActivate: PropTypes.func.isRequired, - className: PropTypes.bool, + className: PropTypes.string, disabled: PropTypes.bool, }; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/RequestsFeed.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/RequestsFeed.js index 07d90709..4b1e2a89 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/RequestsFeed.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/RequestsFeed.js @@ -75,8 +75,12 @@ RequestEventInnerContainer.defaultProps = { isEvent: false, }; -export const RequestEventAvatarContainer = ({ src, hasLine, ...uiProps }) => ( -
+export const RequestEventAvatarContainer = ({ src, hasLine, lineFade, ...uiProps }) => ( +
{src && } {!src && }
@@ -85,11 +89,13 @@ export const RequestEventAvatarContainer = ({ src, hasLine, ...uiProps }) => ( RequestEventAvatarContainer.propTypes = { src: PropTypes.string, hasLine: PropTypes.bool, + lineFade: PropTypes.bool, }; RequestEventAvatarContainer.defaultProps = { src: null, hasLine: false, + lineFade: false, }; export const RequestEventItemIconContainer = ({ name, size, color }) => ( diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineActionEvent.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineActionEvent.js index 5db8d88c..d0df864f 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineActionEvent.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineActionEvent.js @@ -12,7 +12,7 @@ import Overridable from "react-overridable"; import { Feed } from "semantic-ui-react"; import { toRelativeTime } from "react-invenio-forms"; import RequestsFeed from "./RequestsFeed"; -import { TimelineEventBody } from "./TimelineEventBody"; +import TimelineEventBody from "./TimelineEventBody"; class TimelineActionEvent extends Component { render() { @@ -54,6 +54,7 @@ class TimelineActionEvent extends Component { {user} {" "} {toRelativeTime(event.created, i18next.language)} diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventBody.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventBody.js index 7cb19674..7e4c05d4 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventBody.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventBody.js @@ -6,12 +6,122 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import PropTypes from "prop-types"; +import Overridable from "react-overridable"; import { Button, Popup, ButtonGroup } from "semantic-ui-react"; +import { FilesList } from "react-invenio-forms"; import { i18next } from "@translations/invenio_requests/i18next"; -export const TimelineEventBody = ({ payload, quoteReply }) => { +const TimelineEventBody = ({ payload, quoteReply, collapsible, expandedByDefault }) => { + return ( + + + + ); +}; + +TimelineEventBody.propTypes = { + payload: PropTypes.object, + quoteReply: PropTypes.func, + collapsible: PropTypes.bool, + expandedByDefault: PropTypes.bool, +}; + +TimelineEventBody.defaultProps = { + payload: {}, + quoteReply: null, + collapsible: true, + expandedByDefault: false, +}; + +const TimelineEventBodyRender = React.forwardRef( + ( + { + refInner, + isOverflowing, + expanded, + collapsible, + toggleCollapsed, + content, + format, + files, + }, + ref + ) => { + const getCollapsibleClass = () => { + if (!isOverflowing) return ""; + return expanded || !collapsible ? "expanded" : "overflowing"; + }; + + return ( + <> + + + {format === "html" ? ( + + ) : ( + content + )} + {isOverflowing && collapsible && ( + + )} + + + {files !== undefined && } + + ); + } +); +TimelineEventBodyRender.displayName = "TimelineEventBodyRender"; + +TimelineEventBodyRender.propTypes = { + refInner: PropTypes.instanceOf(Element).isRequired, + isOverflowing: PropTypes.bool.isRequired, + expanded: PropTypes.bool.isRequired, + collapsible: PropTypes.bool.isRequired, + toggleCollapsed: PropTypes.func.isRequired, + content: PropTypes.string.isRequired, + format: PropTypes.string, + files: PropTypes.array.isRequired, +}; + +TimelineEventBodyRender.defaultProps = { + format: null, +}; + +const TimelineEventBodyContainer = ({ + payload, + quoteReply, + collapsible, + expandedByDefault, +}) => { const ref = useRef(null); + const refInner = useRef(null); const [selectionRange, setSelectionRange] = useState(null); + const [expanded, setExpanded] = useState(collapsible ? expandedByDefault : true); + const [isOverflowing, setIsOverflowing] = useState(false); useEffect(() => { if (ref.current === null) return; @@ -42,7 +152,34 @@ export const TimelineEventBody = ({ payload, quoteReply }) => { document.addEventListener("selectionchange", onSelectionChange); return () => document.removeEventListener("selectionchange", onSelectionChange); - }, [ref]); + }, []); + + useEffect(() => { + if (!collapsible) return; + + if (expanded) { + setIsOverflowing(true); + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + if (entries.length !== 1) return; + const el = entries[0].target; + setIsOverflowing(el.scrollHeight > el.clientHeight); + }); + + const el = refInner.current; + if (!el) return; + resizeObserver.observe(el); + return () => { + resizeObserver.unobserve(el); + }; + }, [collapsible, expanded]); + + const toggleCollapsed = () => { + if (!collapsible) return; + setExpanded((prev) => !prev); + }; const tooltipOffset = useMemo(() => { if (!selectionRange) return null; @@ -66,10 +203,21 @@ export const TimelineEventBody = ({ payload, quoteReply }) => { window.invenio?.onSearchResultsRendered(); }, []); - const { format, content, event } = payload; + const { format, content, files, event } = payload; if (!quoteReply) { - return {content}; + return ( + + ); } if (event === "comment_deleted") { @@ -90,13 +238,17 @@ export const TimelineEventBody = ({ payload, quoteReply }) => { position="top left" className="requests-event-body-popup" trigger={ - - {format === "html" ? ( - - ) : ( - content - )} - + } basic > @@ -111,12 +263,21 @@ export const TimelineEventBody = ({ payload, quoteReply }) => { ); }; -TimelineEventBody.propTypes = { +TimelineEventBodyContainer.propTypes = { payload: PropTypes.object, quoteReply: PropTypes.func, + collapsible: PropTypes.bool, + expandedByDefault: PropTypes.bool, }; -TimelineEventBody.defaultProps = { +TimelineEventBodyContainer.defaultProps = { payload: {}, quoteReply: null, + collapsible: true, + expandedByDefault: false, }; + +export default Overridable.component( + "InvenioRequests.TimelineEventBody", + TimelineEventBody +); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventPlaceholder.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventPlaceholder.js index 0876ff12..82c72979 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventPlaceholder.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventPlaceholder.js @@ -2,17 +2,18 @@ import React from "react"; import RequestsFeed from "./RequestsFeed"; import { Placeholder, Feed } from "semantic-ui-react"; import Overridable from "react-overridable"; +import PropTypes from "prop-types"; -const TimelineEventPlaceholder = () => { +const TimelineEventPlaceholder = ({ isTiny }) => { return ( <> {/* Comment placeholder */} - + - + @@ -25,31 +26,37 @@ const TimelineEventPlaceholder = () => { {/* Log/Action line placeholder */} - - - - - - - - - - - - - - + {!isTiny ? ( + + + + + + + + + + - - - - - - + + + + + + + + + + + ) : null} ); }; +TimelineEventPlaceholder.propTypes = { + isTiny: PropTypes.bool.isRequired, +}; + export default Overridable.component( "InvenioRequests.TimelineEventPlaceholder", TimelineEventPlaceholder diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/data.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/data.js new file mode 100644 index 00000000..27bed92c --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/data.js @@ -0,0 +1,30 @@ +// This file is part of InvenioRequests +// Copyright (C) 2022-2026 CERN. +// Copyright (C) 2024 Northwestern University. +// 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 { createContext } from "react"; + +export const requestDetailsDiv = document.getElementById("request-detail"); + +export const getDataset = () => { + if (!requestDetailsDiv) { + throw new Error("Could not find div with ID `request-detail`"); + } + + return { + request: JSON.parse(requestDetailsDiv.dataset.record), + defaultQueryParams: JSON.parse(requestDetailsDiv.dataset.defaultQueryConfig), + defaultReplyQueryParams: JSON.parse( + requestDetailsDiv.dataset.defaultReplyQueryConfig + ), + userAvatar: JSON.parse(requestDetailsDiv.dataset.userAvatar), + permissions: JSON.parse(requestDetailsDiv.dataset.permissions), + config: JSON.parse(requestDetailsDiv.dataset.config), + }; +}; + +export const DatasetContext = createContext(null); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/request/RequestDetails.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/request/RequestDetails.js index 26363775..0a860294 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/request/RequestDetails.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/request/RequestDetails.js @@ -10,7 +10,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import Overridable from "react-overridable"; import { Grid } from "semantic-ui-react"; -import { Timeline } from "../timeline"; +import { Timeline } from "../timelineParent"; class RequestDetails extends Component { render() { 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 ad66bc11..d5ad60c1 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/requestsAppInit.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/requestsAppInit.js @@ -31,16 +31,7 @@ import { TimelineUnknownEvent, TimelineReviewersUpdatedEvent, } from "./timelineEvents"; - -const requestDetailsDiv = document.getElementById("request-detail"); -const request = JSON.parse(requestDetailsDiv.dataset.record); -const defaultQueryParams = JSON.parse(requestDetailsDiv.dataset.defaultQueryConfig); -const defaultReplyQueryParams = JSON.parse( - requestDetailsDiv.dataset.defaultReplyQueryConfig -); -const userAvatar = JSON.parse(requestDetailsDiv.dataset.userAvatar); -const permissions = JSON.parse(requestDetailsDiv.dataset.permissions); -const config = JSON.parse(requestDetailsDiv.dataset.config); +import { getDataset, requestDetailsDiv } from "./data"; const defaultComponents = { ...defaultContribComponents, @@ -68,13 +59,8 @@ const overriddenComponents = overrideStore.getAll(); ReactDOM.render( , requestDetailsDiv ); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/state/reducers.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/state/reducers.js index cf566369..c5036831 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/state/reducers.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/state/reducers.js @@ -4,8 +4,7 @@ // 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 { timelineReducer } from "../timeline/state/reducer"; -import { commentEditorReducer } from "../timelineCommentEditor/state/reducer"; +import { timelineReducer } from "../timelineParent/state/reducer"; import { combineReducers } from "redux"; import { requestReducer } from "../request/state/reducer"; import { timelineRepliesReducer } from "../timelineCommentReplies/state/reducer"; @@ -13,7 +12,6 @@ import { timelineRepliesReducer } from "../timelineCommentReplies/state/reducer" export default function createReducers() { return combineReducers({ timeline: timelineReducer, - timelineCommentEditor: commentEditorReducer, timelineReplies: timelineRepliesReducer, request: requestReducer, }); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/state/utils.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/state/utils.js new file mode 100644 index 00000000..3acc92c4 --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/state/utils.js @@ -0,0 +1,38 @@ +// This file is part of InvenioRequests +// Copyright (C) 2026 CERN. +// +// 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. + +/** + * Returns an object to include in an item of `commentStatuses`. + * Either sets `totalHits` if specified, or increases if `increaseCountBy` is defined + */ +export const newOrIncreasedTotalHits = (state, payload) => { + if (payload.totalHits) { + return { totalHits: payload.totalHits }; + } else if (payload.increaseCountBy) { + return { totalHits: state.totalHits + payload.increaseCountBy }; + } + return {}; +}; + +/** + * Given a hits object (of shape { page_number: []hit }), returns the page number and array + * index of the hit specified by `eventId`, or null if it is not found. + */ +export const findEventPageAndIndex = (hits, eventId) => { + let pageNumber = null; + let indexInPage = null; + for (const thisPageNumber of Object.keys(hits)) { + const index = hits[thisPageNumber].findIndex((c) => c.id === eventId); + + if (index !== -1) { + pageNumber = thisPageNumber; + indexInPage = index; + } + } + + if (pageNumber === null || indexInPage === null) return null; + return { pageNumber, indexInPage }; +}; 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..39a56faf 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/store.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/store.js @@ -8,21 +8,16 @@ import { applyMiddleware, createStore } from "redux"; import { composeWithDevTools } from "redux-devtools-extension"; import { default as createReducers } from "./state/reducers"; import thunk from "redux-thunk"; -import { initialState as initialTimeLineState } from "./timeline/state/reducer"; const composeEnhancers = composeWithDevTools({ name: "InvenioRequests", }); export function configureStore(config) { - const { size } = config.defaultQueryParams; - return createStore( createReducers(), // config object will be available in the actions, - { - timeline: { ...initialTimeLineState, size }, - }, + {}, composeEnhancers(applyMiddleware(thunk.withExtraArgument(config))) ); } diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/LoadMore.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/LoadMore.js index 9da44da5..d20f6fa7 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/LoadMore.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/LoadMore.js @@ -6,60 +6,98 @@ // 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. -import React, { Component } from "react"; +import React, { useCallback, useState } from "react"; import { i18next } from "@translations/invenio_requests/i18next"; import { Container, Grid, Button, Segment, Header } from "semantic-ui-react"; import PropTypes from "prop-types"; import Overridable from "react-overridable"; +import TimelineEventPlaceholder from "../components/TimelineEventPlaceholder"; +import RequestsFeed from "../components/RequestsFeed"; -class LoadMore extends Component { - render() { - const { remaining, loading, loadNextAppendedPage } = this.props; - return ( - - - - -
- - - -
- {i18next.t("{{remaining}} older comments", { remaining })} -
- -
-
- -
- - - - - ); - } -} +const LoadMore = ({ count, onClick: _onClick, isTiny, isLoadingAbove }) => { + // We already store `loading` in Redux (for both parent and replies), but we don't store specifically + // which "load more" button is loading. To prevent all visible buttons on the page from showing a loading state + // and showing the placeholder animation (which would make it hard to determine which exact content is loading), + // we store `loading` here locally instead of relying on the Redux value. + const [loading, setLoading] = useState(false); + const onClick = useCallback(async () => { + setLoading(true); + // The dispatched action returns a Promise that resolves when the load completes. + // The Promise should not reject in the event of an API error. + await _onClick(); + setLoading(false); + }, [_onClick]); + + const button = isTiny ? ( + + ) : ( + + + + +
+ + + +
+ {i18next.t("{{remaining}} older comments", { remaining: count })} +
+ +
+
+ +
+ + + + + ); + + return ( + <> + {loading && isLoadingAbove ? ( + + + + ) : null} + {button} + {loading && !isLoadingAbove ? ( + + + + ) : null} + + ); +}; LoadMore.propTypes = { - remaining: PropTypes.number.isRequired, - loading: PropTypes.bool.isRequired, - loadNextAppendedPage: PropTypes.func.isRequired, + count: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired, + isTiny: PropTypes.bool.isRequired, + isLoadingAbove: PropTypes.bool.isRequired, }; export default Overridable.component("LoadMore", LoadMore); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/TimelineFeed.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/TimelineFeed.js index be57305f..b9c418d2 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/TimelineFeed.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/TimelineFeed.js @@ -1,5 +1,5 @@ // This file is part of InvenioRequests -// Copyright (C) 2022 CERN. +// Copyright (C) 2022-2026 CERN. // Copyright (C) 2024 KTH Royal Institute of Technology. // Copyright (C) 2025 Graz University of Technology. // @@ -9,16 +9,31 @@ import PropTypes from "prop-types"; import React, { Component } from "react"; import Overridable from "react-overridable"; -import { Container, Message, Icon } from "semantic-ui-react"; +import { Container, Message, Icon, Button } from "semantic-ui-react"; import Error from "../components/Error"; import Loader from "../components/Loader"; import { DeleteConfirmationModal } from "../components/modals/DeleteConfirmationModal"; -import RequestsFeed from "../components/RequestsFeed"; -import { TimelineCommentEditor } from "../timelineCommentEditor"; -import { TimelineCommentEventControlled } from "../timelineCommentEventControlled"; -import { getEventIdFromUrl } from "../timelineEvents/utils"; -import LoadMore from "./LoadMore"; -import TimelineEventPlaceholder from "../components/TimelineEventPlaceholder"; +import TimelineCommentEditor from "../timelineCommentEditor/TimelineCommentEditor.js"; +import { i18next } from "@translations/invenio_requests/i18next"; +import FakeInput from "../components/FakeInput.js"; +import TimelineFeedElements from "./TimelineFeedElements.js"; + +const TimelineContainerElement = ({ children, isReplyTimeline }) => { + if (isReplyTimeline) { + return
{children}
; + } else { + return ( + + {children} + + ); + } +}; + +TimelineContainerElement.propTypes = { + children: PropTypes.node.isRequired, + isReplyTimeline: PropTypes.bool.isRequired, +}; class TimelineFeed extends Component { constructor(props) { @@ -27,193 +42,177 @@ class TimelineFeed extends Component { this.state = { modalOpen: false, modalAction: null, + expanded: true, }; } - componentDidMount() { - const { getTimelineWithRefresh } = this.props; - - // Check if an event ID is included in the hash - getTimelineWithRefresh(getEventIdFromUrl()); - } - - async componentDidUpdate(prevProps) { - const { timeline } = this.props; - - const hasNewComments = - prevProps.timeline?.lastPageData?.hits?.total !== - timeline?.lastPageData?.hits?.total; - if (hasNewComments) { - await window.MathJax?.typesetPromise(); - } - } - - componentWillUnmount() { - const { timelineStopRefresh } = this.props; - timelineStopRefresh(); - } + loadPage = (page) => { + const { fetchPage } = this.props; + return fetchPage(page); + }; - loadNextAppendedPage = () => { - const { fetchNextTimelinePage } = this.props; - fetchNextTimelinePage("first"); + onOpenModal = (action) => { + this.setState({ modalOpen: true, modalAction: action }); }; - loadNextPageAfterFocused = () => { - const { fetchNextTimelinePage } = this.props; - fetchNextTimelinePage("focused"); + onRepliesClick = () => { + this.setState((state) => ({ expanded: !state.expanded })); }; - onOpenModal = (action) => { - this.setState({ modalOpen: true, modalAction: action }); + onCancelClick = () => { + const { setIsReplying, clearDraft } = this.props; + setIsReplying(false); + clearDraft(); }; - renderHitList = (hits) => { - const { userAvatar, permissions } = this.props; + onFakeInputActivate = () => { + const { setIsReplying } = this.props; + setIsReplying(true); + }; - return ( - <> - {hits.map((event) => ( - - ))} - - ); + appendCommentContent = (eventId, content) => { + const { appendCommentContent, parentRequestEvent } = this.props; + if (parentRequestEvent) { + appendCommentContent(parentRequestEvent.id, content); + } else { + appendCommentContent(eventId, content); + } }; render() { const { - timeline, initialLoading, error, userAvatar, request, permissions, warning, + parentRequestEvent, + isSubmitting, + commentContent, + storedCommentContent, + appendedCommentContent, + setCommentContent, + restoreCommentContent, + setCommentFiles, + restoreCommentFiles, + submissionError, + submitComment, + totalHits, + replying, + hits, + pageNumbers, size, + updateComment, + deleteComment, + draftFiles, } = this.props; - const { modalOpen, modalAction } = this.state; - const { - firstPageHits, - lastPageHits, - focusedPageHits, - afterFirstPageHits, - afterFocusedPageHits, - focusedPage, - pageAfterFocused, - lastPage, - totalHits, - loadingAfterFirstPage, - loadingAfterFocusedPage, - } = timeline; - - let remainingBeforeFocused = 0; - let remainingAfterFocused = 0; - - if (focusedPage && focusedPage !== lastPage) { - remainingBeforeFocused = - (focusedPage - 1) * size - (firstPageHits.length + afterFirstPageHits.length); - remainingAfterFocused = - totalHits - (pageAfterFocused * size + lastPageHits.length); - } else { - remainingBeforeFocused = - totalHits - - (firstPageHits.length + afterFirstPageHits.length + lastPageHits.length); - } + const { modalOpen, modalAction, expanded } = this.state; - const firstFeedClassName = remainingBeforeFocused > 0 ? "gradient-feed" : null; - const lastFeedClassName = - remainingAfterFocused > 0 || (remainingBeforeFocused > 0 && focusedPage === null) - ? "stretched-feed gradient-feed" - : null; - const focusedFeedClassName = - (focusedPage !== null && remainingBeforeFocused > 0 ? "stretched-feed" : "") + - (remainingAfterFocused > 0 ? " gradient-feed" : ""); + const isReplyTimeline = parentRequestEvent !== null; + const hasHits = totalHits !== 0; return ( - {warning && ( - -

- - {warning} -

-
- )} - - - + + {warning && ( + +

+ + {warning} +

+
+ )} - {/* First Feed before focused page (oldest comments) */} - - {this.renderHitList(firstPageHits)} - - {/* Events before focused page */} - {afterFirstPageHits && this.renderHitList(afterFirstPageHits)} - {loadingAfterFirstPage && } - - - {/* LoadMore button for events before focused */} - {remainingBeforeFocused > 0 && ( - - )} + ) : null} - {/* Focused Feed */} - {focusedPageHits && ( - <> - - {/* Events at focused page */} - {this.renderHitList(focusedPageHits)} - - {/* Events after focused page */} - {this.renderHitList(afterFocusedPageHits)} - {loadingAfterFocusedPage && } - - - {/* LoadMore button for events after focused */} - {remainingAfterFocused > 0 && ( - - )} - - )} + {isReplyTimeline && hasHits ? ( + + ) : null} - {/* Last Feed (newest comments) */} - {lastPageHits.length > 0 && ( - - {this.renderHitList(lastPageHits)} - + {expanded && hasHits ? ( + + ) : null} + + {!replying && isReplyTimeline ? ( + + ) : ( + )} - this.setState({ modalOpen: true })} onClose={() => this.setState({ modalOpen: false })} /> -
+
@@ -222,31 +221,49 @@ class TimelineFeed extends Component { } TimelineFeed.propTypes = { - getTimelineWithRefresh: PropTypes.func.isRequired, - timelineStopRefresh: PropTypes.func.isRequired, - fetchNextTimelinePage: PropTypes.func.isRequired, - appendPage: PropTypes.func.isRequired, - setLoadingForLoadMore: PropTypes.func.isRequired, - timeline: PropTypes.object, - error: PropTypes.object, + hits: PropTypes.object.isRequired, + pageNumbers: PropTypes.array.isRequired, + totalHits: PropTypes.number.isRequired, + fetchPage: PropTypes.func.isRequired, + error: PropTypes.string, isSubmitting: PropTypes.bool, - page: PropTypes.number, - size: PropTypes.number, + size: PropTypes.number.isRequired, userAvatar: PropTypes.string, request: PropTypes.object.isRequired, permissions: PropTypes.object.isRequired, initialLoading: PropTypes.bool.isRequired, warning: PropTypes.string, + parentRequestEvent: PropTypes.object, + commentContent: PropTypes.string.isRequired, + storedCommentContent: PropTypes.string, + appendedCommentContent: PropTypes.string, + setCommentContent: PropTypes.func.isRequired, + restoreCommentContent: PropTypes.func.isRequired, + draftFiles: PropTypes.array.isRequired, + restoreCommentFiles: PropTypes.func.isRequired, + setCommentFiles: PropTypes.func.isRequired, + submissionError: PropTypes.string, + submitComment: PropTypes.func.isRequired, + updateComment: PropTypes.func.isRequired, + deleteComment: PropTypes.func.isRequired, + appendCommentContent: PropTypes.func.isRequired, + replying: PropTypes.bool, + setIsReplying: PropTypes.func, + clearDraft: PropTypes.func, }; TimelineFeed.defaultProps = { - timeline: null, error: null, isSubmitting: false, - page: 1, - size: 10, userAvatar: "", warning: null, + parentRequestEvent: null, + storedCommentContent: null, + submissionError: null, + replying: false, + setIsReplying: null, + appendedCommentContent: null, + clearDraft: null, }; export default Overridable.component("TimelineFeed", TimelineFeed); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/TimelineFeedElements.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/TimelineFeedElements.js new file mode 100644 index 00000000..733c48b9 --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/TimelineFeedElements.js @@ -0,0 +1,251 @@ +// This file is part of InvenioRequests +// Copyright (C) 2026 CERN. +// +// 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. + +import Overridable from "react-overridable"; +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import _cloneDeep from "lodash/cloneDeep"; +import LoadMore from "./LoadMore"; +import RequestsFeed from "../components/RequestsFeed"; +import TimelineCommentEventControlled from "../timelineCommentEventControlled/TimelineCommentEventControlled.js"; +import { Divider } from "semantic-ui-react"; + +const TimelineFeedElementRequestFeed = ({ + userAvatar, + permissions, + request, + updateComment, + deleteComment, + parentRequestEvent, + isBeforeLoadMore, + isAfterLoadMore, + openConfirmModal, + hits, + appendCommentContent, +}) => { + return ( + + {hits.map((event, index) => ( + appendCommentContent(event.id, content)} + isReply={!!parentRequestEvent} + isBeforeLoadMore={isBeforeLoadMore && index === hits.length - 1} + /> + ))} + + ); +}; + +TimelineFeedElementRequestFeed.propTypes = { + userAvatar: PropTypes.string, + permissions: PropTypes.object.isRequired, + request: PropTypes.object.isRequired, + updateComment: PropTypes.func.isRequired, + deleteComment: PropTypes.func.isRequired, + parentRequestEvent: PropTypes.object, + isBeforeLoadMore: PropTypes.bool.isRequired, + isAfterLoadMore: PropTypes.bool.isRequired, + openConfirmModal: PropTypes.func.isRequired, + hits: PropTypes.array.isRequired, + appendCommentContent: PropTypes.func.isRequired, +}; + +TimelineFeedElementRequestFeed.defaultProps = { + parentRequestEvent: null, + userAvatar: null, +}; + +/** + * Converts the Redux `hits` object into a series of "instructions" for rendering contiguous feed blocks + * and load-more buttons. The instructions are then rendered into React elements. We don't directly generate + * React elements here to allow easily appending contiguous children to the same RequestFeed and also to + * somewhat preserve the declarativeness of React. + */ +const TimelineFeedElements = ({ + hits, + pageNumbers, + size, + totalHits, + loadPage, + userAvatar, + permissions, + request, + parentRequestEvent, + updateComment, + deleteComment, + appendCommentContent, + openConfirmModal, +}) => { + const isReplyTimeline = !!parentRequestEvent; + + const feedElements = useMemo(() => { + // Clone the hits object to avoid accidentally modifying the Redux store + const clonedHits = _cloneDeep(hits); + + const reversePages = isReplyTimeline; + let iterPageNumbers = pageNumbers; + if (reversePages) { + iterPageNumbers = iterPageNumbers.toReversed(); + } + + // Exclude hits on the virtual "page zero", which are new comments added by the user since page load. + // We do not need to count these for the purpose of the load more buttons. + const pageZeroHits = iterPageNumbers.includes(0) ? clonedHits[0].length : 0; + const iterTotalHits = totalHits - pageZeroHits; + + const elements = []; + iterPageNumbers.forEach((pageNumber, i) => { + // If we are on the top-most page + if (i === 0) { + if (!reversePages) { + // If rendering in normal order, this means we have some non-loaded pages at the top of the page. + if (pageNumber > 1) { + elements.push({ + type: "LoadMore", + page: pageNumber - 1, + count: (pageNumber - 1) * size, + key: "LoadMore-" + pageNumber, + isLoadingAbove: false, + }); + } + + elements.push({ + type: "RequestFeed", + children: clonedHits[pageNumber], + key: "RequestFeed-" + pageNumber, + }); + } else { + // We are not starting from page 1, so we need to figure out how many pages have not yet been loaded + // at the top of the page. If it's not zero, we need a button. + const lastPage = Math.ceil(iterTotalHits / size); + const lastLoadedPage = iterPageNumbers[0]; + const difference = lastPage - lastLoadedPage; + if (difference > 0) { + elements.push({ + type: "LoadMore", + page: lastLoadedPage + 1, + count: iterTotalHits - lastLoadedPage * size, + key: "LoadMore-" + (lastLoadedPage + 1), + isLoadingAbove: false, + }); + } + elements.push({ + type: "RequestFeed", + children: clonedHits[pageNumber], + key: "RequestFeed-" + pageNumber, + }); + } + + return; + } + + // Check if there is a gap in the page numbers, and add a button if there is. + const previousPageNumber = iterPageNumbers[i - 1]; + const difference = reversePages + ? previousPageNumber - pageNumber + : pageNumber - previousPageNumber; + if (difference > 1) { + const pageToLoad = reversePages ? pageNumber + 1 : pageNumber - 1; + elements.push({ + type: "LoadMore", + page: pageToLoad, + count: (difference - 1) * size, + + key: "LoadMore-" + pageNumber, + isLoadingAbove: false, + }); + elements.push({ + type: "RequestFeed", + children: clonedHits[pageNumber], + key: "RequestFeed-" + pageNumber, + }); + return; + } + + // Append the children of this page to the previous RequestFeed. + // To preserve the correct margins, padding, etc, we want to add the items to an + // existing RequestFeed instead of creating a new one. + elements[elements.length - 1].children.push(...clonedHits[pageNumber]); + }); + + return elements; + }, [hits, totalHits, pageNumbers, size, isReplyTimeline]); + + return ( + // For CSS, we need this to be inside a div +
+ {feedElements.map((el, index) => + el.type === "LoadMore" ? ( + loadPage(el.page)} + isTiny={isReplyTimeline} + isLoadingAbove={el.isLoadingAbove} + /> + ) : ( + 0 && feedElements[index - 1].type === "LoadMore"} + openConfirmModal={openConfirmModal} + hits={el.children} + /> + ) + )} + {isReplyTimeline ? : null} +
+ ); +}; + +TimelineFeedElements.propTypes = { + hits: PropTypes.object.isRequired, + pageNumbers: PropTypes.array.isRequired, + size: PropTypes.number.isRequired, + totalHits: PropTypes.number.isRequired, + loadPage: PropTypes.func.isRequired, + userAvatar: PropTypes.string, + permissions: PropTypes.object.isRequired, + request: PropTypes.object.isRequired, + parentRequestEvent: PropTypes.object, + updateComment: PropTypes.func.isRequired, + deleteComment: PropTypes.func.isRequired, + appendCommentContent: PropTypes.func.isRequired, + openConfirmModal: PropTypes.func.isRequired, +}; +TimelineFeedElements.defaultProps = { + userAvatar: null, + parentRequestEvent: null, +}; + +export default Overridable.component( + "InvenioRequests.TimelineFeed.Elements", + TimelineFeedElements +); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/index.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/index.js deleted file mode 100644 index 9a58c544..00000000 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/index.js +++ /dev/null @@ -1,40 +0,0 @@ -// This file is part of InvenioRequests -// Copyright (C) 2022 CERN. -// -// 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 { connect } from "react-redux"; -import { - getTimelineWithRefresh, - clearTimelineInterval, - appendPage, - setLoadingForLoadMore, - fetchNextTimelinePage, -} from "./state/actions"; -import TimelineFeedComponent from "./TimelineFeed"; - -const mapDispatchToProps = (dispatch) => ({ - getTimelineWithRefresh: (includeEventId) => - dispatch(getTimelineWithRefresh(includeEventId)), - timelineStopRefresh: () => dispatch(clearTimelineInterval()), - fetchNextTimelinePage: (after) => dispatch(fetchNextTimelinePage(after)), - appendPage: (payload) => dispatch(appendPage(payload)), - setLoadingForLoadMore: (type) => dispatch(setLoadingForLoadMore(type)), -}); - -const mapStateToProps = (state) => ({ - initialLoading: state.timeline.initialLoading, - lastPageRefreshing: state.timeline.lastPageRefreshing, - timeline: state.timeline, - error: state.timeline.error, - isSubmitting: state.timelineCommentEditor.isLoading, - size: state.timeline.size, - page: state.timeline.page, - warning: state.timeline.warning, -}); - -export const Timeline = connect( - mapStateToProps, - mapDispatchToProps -)(TimelineFeedComponent); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/state/actions.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/state/actions.js deleted file mode 100644 index 41e43e46..00000000 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/state/actions.js +++ /dev/null @@ -1,241 +0,0 @@ -// This file is part of InvenioRequests -// Copyright (C) 2022 CERN. -// -// 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. - -export const IS_LOADING = "timeline/IS_LOADING"; -export const SUCCESS = "timeline/SUCCESS"; -export const HAS_ERROR = "timeline/HAS_ERROR"; -export const IS_REFRESHING = "timeline/REFRESHING"; -export const MISSING_REQUESTED_EVENT = "timeline/MISSING_REQUESTED_EVENT"; -export const PARENT_UPDATED_COMMENT = "timeline/PARENT_UPDATED_COMMENT"; -export const PARENT_DELETED_COMMENT = "timeline/PARENT_DELETED_COMMENT"; -export const APPEND_PAGE = "timeline/APPEND_PAGE"; -export const LOADING_AFTER_FIRST_PAGE = "timeline/LOADING_AFTER_FIRST_PAGE"; -export const LOADING_AFTER_FOCUSED_PAGE = "timeline/LOADING_AFTER_FOCUSED_PAGE"; - -class intervalManager { - static IntervalId = undefined; - - static setIntervalId(intervalId) { - this.intervalId = intervalId; - } - - static resetInterval() { - clearInterval(this.intervalId); - delete this.intervalId; - } -} - -export const setLoadingForLoadMore = (type) => { - return { - type: type, - }; -}; - -export const appendPage = (payload) => { - return { - type: APPEND_PAGE, - payload: payload, - }; -}; - -export const fetchTimeline = (focusEventId = undefined) => { - return async (dispatch, getState, config) => { - const { size } = getState().timeline; - - dispatch({ type: IS_REFRESHING }); - - try { - const firstPageResponse = await config.requestsApi.getTimeline({ - size, - page: 1, - sort: "oldest", - }); - - const totalHits = firstPageResponse.data.hits.total || 0; - const lastPageNumber = Math.ceil(totalHits / size); - - let lastPageResponse = null; - if (lastPageNumber > 1) { - // Always fetch last page - lastPageResponse = await config.requestsApi.getTimeline({ - size, - page: lastPageNumber, - sort: "oldest", - }); - } - - let focusedPage = null; - let focusedPageResponse = null; - - if (focusEventId) { - // Check if focused event is on first or last page - const existsOnFirstPage = firstPageResponse.data.hits.hits.some( - (h) => h.id === focusEventId - ); - const existsOnLastPage = lastPageResponse?.data.hits.hits.some( - (h) => h.id === focusEventId - ); - - if (existsOnFirstPage) { - focusedPage = 1; - } else if (existsOnLastPage && lastPageNumber > 1) { - focusedPage = lastPageNumber; - } else { - // Fetch focused event info to know which page it's on - focusedPageResponse = await config.requestsApi.getTimelineFocused( - focusEventId, - { - size, - sort: "oldest", - } - ); - focusedPage = focusedPageResponse?.data?.page; - - if (focusedPageResponse.data.hits.hits.length === 0) { - dispatch({ type: MISSING_REQUESTED_EVENT }); - } - } - } - - dispatch({ - type: SUCCESS, - payload: { - firstPageHits: firstPageResponse.data.hits.hits, - focusedPageHits: focusedPageResponse?.data.hits.hits, - lastPageHits: lastPageResponse?.data.hits.hits, - totalHits: totalHits, - focusedPage: focusedPage, - pageAfterFocused: focusedPage, - lastPage: lastPageNumber, - }, - }); - } catch (error) { - dispatch({ - type: HAS_ERROR, - payload: error, - }); - } - }; -}; - -export const fetchNextTimelinePage = (after) => { - return async (dispatch, getState, config) => { - const { size, page, pageAfterFocused } = getState().timeline; - - let loadingEvent; - let pageToLoad; - if (after === "first") { - loadingEvent = LOADING_AFTER_FIRST_PAGE; - pageToLoad = page + 1; - } else if (after === "focused") { - loadingEvent = LOADING_AFTER_FOCUSED_PAGE; - pageToLoad = pageAfterFocused + 1; - } else { - throw new Error("Invalid `after` value"); - } - - dispatch({ - type: loadingEvent, - }); - - const response = await config.requestsApi.getTimeline({ - size, - page: pageToLoad, - sort: "oldest", - }); - - dispatch({ - type: APPEND_PAGE, - payload: { - after, - newHits: response.data.hits.hits, - page: pageToLoad, - }, - }); - }; -}; - -export const fetchLastTimelinePage = () => { - return async (dispatch, getState, config) => { - const state = getState(); - const { size, totalHits } = state.timeline; - - if (totalHits === 0) return; - - const lastPageNumber = Math.ceil(totalHits / size); - - // Only fetch last page if there are more than 1 page - if (lastPageNumber <= 1) return; - - dispatch({ type: IS_REFRESHING }); - - try { - const response = await config.requestsApi.getTimeline({ - size, - page: lastPageNumber, - sort: "oldest", - }); - - dispatch({ - type: SUCCESS, - payload: { - lastPageHits: response.data.hits.hits, - totalHits: response.data.hits.total, - lastPage: lastPageNumber, - }, - }); - } catch (error) { - dispatch({ type: HAS_ERROR, payload: error }); - } - }; -}; - -const timelineReload = (dispatch, getState) => { - const state = getState(); - const { initialLoading, lastPageRefreshing, error } = state.timeline; - const { isLoading: isSubmitting } = state.timelineCommentEditor; - - if (error) { - dispatch(clearTimelineInterval()); - } - - const concurrentRequests = initialLoading || lastPageRefreshing || isSubmitting; - if (concurrentRequests) return; - - // Fetch only the last page - dispatch(fetchLastTimelinePage()); -}; - -export const getTimelineWithRefresh = (focusEventId) => { - return async (dispatch) => { - dispatch({ - type: IS_LOADING, - }); - // Fetch both first and last pages - await dispatch(fetchTimeline(focusEventId)); - dispatch(setTimelineInterval()); - }; -}; - -export const setTimelineInterval = () => { - return async (dispatch, getState, config) => { - const intervalAlreadySet = intervalManager.intervalId; - - if (!intervalAlreadySet) { - const intervalId = setInterval( - () => timelineReload(dispatch, getState, config), - config.refreshIntervalMs - ); - intervalManager.setIntervalId(intervalId); - } - }; -}; - -export const clearTimelineInterval = () => { - return () => { - intervalManager.resetInterval(); - }; -}; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/state/reducer.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/state/reducer.js deleted file mode 100644 index 52ee4acd..00000000 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/state/reducer.js +++ /dev/null @@ -1,181 +0,0 @@ -// This file is part of InvenioRequests -// Copyright (C) 2022 CERN. -// -// 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 { i18next } from "@translations/invenio_requests/i18next"; -import { - HAS_ERROR, - IS_LOADING, - IS_REFRESHING, - MISSING_REQUESTED_EVENT, - PARENT_DELETED_COMMENT, - PARENT_UPDATED_COMMENT, - SUCCESS, - APPEND_PAGE, - LOADING_AFTER_FIRST_PAGE, - LOADING_AFTER_FOCUSED_PAGE, -} from "./actions"; -import _cloneDeep from "lodash/cloneDeep"; - -export const initialState = { - initialLoading: false, - lastPageRefreshing: false, - firstPageHits: [], - afterFirstPageHits: [], - focusedPageHits: [], - afterFocusedPageHits: [], - lastPageHits: [], - totalHits: 0, - error: null, - size: 15, - // The last loaded page after the first page but before the focused page (if any) - page: 1, - // The page number that the focused event belongs to. - focusedPage: null, - // The last loaded page after the focused page but before the last page. - pageAfterFocused: null, - lastPage: null, - warning: null, - loadingAfterFirstPage: false, - loadingAfterFocusedPage: false, -}; - -const newStateWithUpdate = (updatedComment, timelineState) => { - const timelineClone = _cloneDeep(timelineState); - - const updateHits = (hitsArray) => { - if (!hitsArray) return; - const idx = hitsArray.findIndex((c) => c.id === updatedComment.id); - if (idx !== -1) hitsArray[idx] = updatedComment; - }; - - // Update in firstPageData, afterFirstPageHits, focusedPageData, afterFocusedPageHits, lastPageData - updateHits(timelineClone.firstPageHits); - updateHits(timelineClone.afterFirstPageHits); - updateHits(timelineClone.focusedPageHits); - updateHits(timelineClone.afterFocusedPageHits); - updateHits(timelineClone.lastPageHits); - - return timelineClone; -}; - -const newStateWithDelete = (requestEventId, timelineState) => { - const timelineClone = _cloneDeep(timelineState); - const deletionPayload = { - content: "comment was deleted", - event: "comment_deleted", - format: "html", - }; - - const replaceInHits = (hitsArray) => { - if (!hitsArray) return; - const idx = hitsArray.findIndex((c) => c.id === requestEventId); - if (idx !== -1) { - hitsArray[idx] = { - ...hitsArray[idx], - type: "L", - payload: deletionPayload, - }; - } - }; - - // Delete in firstPageData, afterFirstPageHits, focusedPageData, afterFocusedPageHits, lastPageData - replaceInHits(timelineClone.firstPageHits); - replaceInHits(timelineClone.afterFirstPageHits); - replaceInHits(timelineClone.focusedPageHits); - replaceInHits(timelineClone.afterFocusedPageHits); - replaceInHits(timelineClone.lastPageHits); - - return timelineClone; -}; - -const newStateWithAppendedHits = (newHits, after, state) => { - if (after === "first") { - return { - ...state, - afterFirstPageHits: [...state.afterFirstPageHits, ...newHits], - }; - } else if (after === "focused") { - return { - ...state, - afterFocusedPageHits: [...state.afterFocusedPageHits, ...newHits], - }; - } else { - throw new Error("Invalid `after` value"); - } -}; - -export const timelineReducer = (state = initialState, action) => { - switch (action.type) { - case IS_LOADING: - return { ...state, initialLoading: true }; - case IS_REFRESHING: - return { ...state, lastPageRefreshing: true }; - case SUCCESS: - return { - ...state, - lastPageRefreshing: false, - initialLoading: false, - firstPageHits: action.payload.firstPageHits ?? state.firstPageHits, - afterFirstPageHits: - action.payload.afterFirstPageHits ?? state.afterFirstPageHits, - focusedPageHits: action.payload.focusedPageHits ?? state.focusedPageHits, - afterFocusedPageHits: - action.payload.afterFocusedPageHits ?? state.afterFocusedPageHits, - lastPageHits: action.payload.lastPageHits ?? state.lastPageHits, - focusedPage: action.payload.focusedPage ?? state.focusedPage, - pageAfterFocused: action.payload.pageAfterFocused ?? state.pageAfterFocused, - lastPage: action.payload.lastPage ?? state.lastPage, - totalHits: action.payload.totalHits ?? state.totalHits, - error: null, - }; - case APPEND_PAGE: - return { - ...newStateWithAppendedHits( - action.payload.newHits, - action.payload.after, - state - ), - page: action.payload.after === "first" ? action.payload.page : state.page, - pageAfterFocused: - action.payload.after === "focused" - ? action.payload.page - : state.pageAfterFocused, - loadingAfterFirstPage: - action.payload.after === "first" ? false : state.loadingAfterFirstPage, - loadingAfterFocusedPage: - action.payload.after === "focused" ? false : state.loadingAfterFocusedPage, - }; - case HAS_ERROR: - return { - ...state, - lastPageRefreshing: false, - initialLoading: false, - error: action.payload, - }; - case MISSING_REQUESTED_EVENT: - return { - ...state, - warning: i18next.t("We couldn't find the comment you were looking for."), - }; - case PARENT_UPDATED_COMMENT: - return newStateWithUpdate(action.payload.updatedComment, state); - case PARENT_DELETED_COMMENT: - return newStateWithDelete(action.payload.deletedCommentId, state); - case LOADING_AFTER_FIRST_PAGE: - return { - ...state, - loadingAfterFirstPage: true, - }; - case LOADING_AFTER_FOCUSED_PAGE: - return { - ...state, - loadingAfterFocusedPage: true, - }; - - default: - return state; - } -}; 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 b27b4b35..e99bfdb8 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 @@ -11,6 +11,7 @@ import { Container, Message, Icon } from "semantic-ui-react"; import PropTypes from "prop-types"; import { i18next } from "@translations/invenio_requests/i18next"; import { RequestEventAvatarContainer } from "../components/RequestsFeed"; +import { InvenioRequestFilesApi } from "../api/InvenioRequestFilesApi"; const TimelineCommentEditor = ({ isLoading, @@ -19,6 +20,9 @@ const TimelineCommentEditor = ({ restoreCommentContent, setCommentContent, appendedCommentContent, + files, + restoreCommentFiles, + setCommentFiles, error, submitComment, userAvatar, @@ -28,11 +32,16 @@ const TimelineCommentEditor = ({ saveButtonIcon, onCancel, disabled, + request, }) => { useEffect(() => { restoreCommentContent(); }, [restoreCommentContent]); + useEffect(() => { + restoreCommentFiles(); + }, [restoreCommentFiles]); + const editorRef = useRef(null); useEffect(() => { if (!appendedCommentContent || !editorRef.current) return; @@ -52,6 +61,16 @@ const TimelineCommentEditor = ({ [autoFocus] ); + const onFileUpload = async (filename, payload, options) => { + const client = new InvenioRequestFilesApi(); + return await client.uploadFile(request.id, filename, payload, options); + }; + + const onFileDelete = async (file) => { + const client = new InvenioRequestFilesApi(); + await client.deleteFile(request.id, file.key); + }; + return (
{error && {error}} @@ -80,6 +99,12 @@ const TimelineCommentEditor = ({ onInit={onInit} minHeight={150} disabled={!canCreateComment || disabled} + files={files} + onFilesChange={(files) => { + setCommentFiles(files); + }} + onFileUpload={onFileUpload} + onFileDelete={onFileDelete} />
@@ -98,7 +123,7 @@ const TimelineCommentEditor = ({ content={saveButtonLabel} loading={isLoading} onClick={() => - commentContent.length > 0 && submitComment(commentContent, "html") + commentContent.length > 0 && submitComment(commentContent, "html", files) } disabled={!canCreateComment} aria-disabled={commentContent.length > 0} @@ -110,6 +135,9 @@ const TimelineCommentEditor = ({ TimelineCommentEditor.propTypes = { commentContent: PropTypes.string, + files: PropTypes.array, + restoreCommentFiles: PropTypes.func.isRequired, + setCommentFiles: PropTypes.func.isRequired, storedCommentContent: PropTypes.string, appendedCommentContent: PropTypes.string, isLoading: PropTypes.bool, @@ -124,10 +152,12 @@ TimelineCommentEditor.propTypes = { saveButtonIcon: PropTypes.string, onCancel: PropTypes.func, disabled: PropTypes.bool, + request: PropTypes.object.isRequired, }; TimelineCommentEditor.defaultProps = { commentContent: "", + files: [], storedCommentContent: null, appendedCommentContent: "", isLoading: false, diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/draftStorage.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/draftStorage.js new file mode 100644 index 00000000..ccf9dc02 --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/draftStorage.js @@ -0,0 +1,34 @@ +// This file is part of InvenioRequests +// Copyright (C) 2026 CERN. +// +// 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. + +const draftCommentKey = (requestId, parentRequestEventId) => + `draft-comment-${requestId}${parentRequestEventId ? "-" + parentRequestEventId : ""}`; +export const setDraftComment = (requestId, parentRequestEventId, content) => { + localStorage.setItem(draftCommentKey(requestId, parentRequestEventId), content); +}; +export const getDraftComment = (requestId, parentRequestEventId) => { + return localStorage.getItem(draftCommentKey(requestId, parentRequestEventId)); +}; +export const deleteDraftComment = (requestId, parentRequestEventId) => { + localStorage.removeItem(draftCommentKey(requestId, parentRequestEventId)); +}; + +const draftFilesKey = (requestId, parentRequestEventId) => + `draft-files-${requestId}${parentRequestEventId ? "-" + parentRequestEventId : ""}`; +export const setDraftFiles = (requestId, parentRequestEventId, files) => { + localStorage.setItem( + draftFilesKey(requestId, parentRequestEventId), + JSON.stringify(files) + ); +}; +export const getDraftFiles = (requestId, parentRequestEventId) => { + return JSON.parse( + localStorage.getItem(draftFilesKey(requestId, parentRequestEventId)) + ); +}; +export const deleteDraftFiles = (requestId, parentRequestEventId) => { + localStorage.removeItem(draftFilesKey(requestId, parentRequestEventId)); +}; 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 deleted file mode 100644 index a842c6a2..00000000 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/index.js +++ /dev/null @@ -1,35 +0,0 @@ -// This file is part of InvenioRequests -// Copyright (C) 2022-2025 CERN. -// -// 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 { connect } from "react-redux"; -import { - submitComment, - setEventContent, - restoreEventContent, - PARENT_SET_DRAFT_CONTENT, - PARENT_RESTORE_DRAFT_CONTENT, -} from "./state/actions"; -import TimelineCommentEditorComponent from "./TimelineCommentEditor"; - -const mapDispatchToProps = { - submitComment, - setCommentContent: (content) => - setEventContent(content, null, PARENT_SET_DRAFT_CONTENT), - restoreCommentContent: () => restoreEventContent(null, PARENT_RESTORE_DRAFT_CONTENT), -}; - -const mapStateToProps = (state) => ({ - isLoading: state.timelineCommentEditor.isLoading, - error: state.timelineCommentEditor.error, - commentContent: state.timelineCommentEditor.commentContent, - storedCommentContent: state.timelineCommentEditor.storedCommentContent, - appendedCommentContent: state.timelineCommentEditor.appendedCommentContent, -}); - -export const TimelineCommentEditor = connect( - mapStateToProps, - mapDispatchToProps -)(TimelineCommentEditorComponent); 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 2df6ca49..f0e8432e 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 @@ -4,33 +4,14 @@ // 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 { errorSerializer, payloadSerializer } from "../../api/serializers"; import { - clearTimelineInterval, - setTimelineInterval, - SUCCESS as TIMELINE_SUCCESS, -} from "../../timeline/state/actions"; -import _cloneDeep from "lodash/cloneDeep"; + getDraftComment, + setDraftComment, + getDraftFiles, + setDraftFiles, +} from "../draftStorage"; -export const IS_LOADING = "eventEditor/IS_LOADING"; -export const HAS_ERROR = "eventEditor/HAS_ERROR"; -export const SUCCESS = "eventEditor/SUCCESS"; -export const PARENT_SET_DRAFT_CONTENT = "eventEditor/SETTING_CONTENT"; -export const PARENT_RESTORE_DRAFT_CONTENT = "eventEditor/RESTORE_CONTENT"; - -const draftCommentKey = (requestId, parentRequestEventId) => - `draft-comment-${requestId}${parentRequestEventId ? "-" + parentRequestEventId : ""}`; -export const setDraftComment = (requestId, parentRequestEventId, content) => { - localStorage.setItem(draftCommentKey(requestId, parentRequestEventId), content); -}; -export const getDraftComment = (requestId, parentRequestEventId) => { - return localStorage.getItem(draftCommentKey(requestId, parentRequestEventId)); -}; -export const deleteDraftComment = (requestId, parentRequestEventId) => { - localStorage.removeItem(draftCommentKey(requestId, parentRequestEventId)); -}; - -export const setEventContent = (content, parentRequestEventId, event) => { +export const setDraftContent = (content, parentRequestEventId, event) => { return async (dispatch, getState) => { dispatch({ type: event, @@ -52,7 +33,7 @@ export const setEventContent = (content, parentRequestEventId, event) => { }; }; -export const restoreEventContent = (parentRequestEventId, event) => { +export const restoreDraftContent = (parentRequestEventId, event) => { return (dispatch, getState) => { const { request } = getState(); let savedDraft = null; @@ -74,57 +55,46 @@ export const restoreEventContent = (parentRequestEventId, event) => { }; }; -export const submitComment = (content, format) => { +export const setEventFiles = (files, parentRequestEventId, event) => { return async (dispatch, getState, config) => { - const { timeline: timelineState, request } = getState(); - - dispatch(clearTimelineInterval()); - dispatch({ - type: IS_LOADING, + type: event, + payload: { + parentRequestEventId, + files, + }, }); - - const payload = payloadSerializer(content, format || "html"); + const { request } = getState(); try { - /* Because of the delay in ES indexing we need to handle the updated state on the client-side until it is ready to be retrieved from the server.*/ - - const response = await config.requestsApi.submitComment(payload); - - dispatch({ type: SUCCESS }); + setDraftFiles(request.data.id, parentRequestEventId, files); + } catch (e) { + // This should not be a fatal error. The comment editor is still usable if + // draft saving isn't working (e.g. on very old browsers or ultra-restricted + // environments with 0 storage quota.) + console.warn("Failed to save comment files:", e); + } + }; +}; - try { - deleteDraftComment(request.data.id); - } catch (e) { - console.warn("Failed to delete saved comment:", e); - } +export const restoreEventFiles = (parentRequestEventId, event) => { + return (dispatch, getState, config) => { + const { request } = getState(); + let savedDraftFiles = null; + try { + savedDraftFiles = getDraftFiles(request.data.id, parentRequestEventId); + } catch (e) { + console.warn("Failed to get saved files:", e); + } - await dispatch({ - type: TIMELINE_SUCCESS, - payload: _updatedState(response.data, timelineState), - }); - dispatch(setTimelineInterval()); - } catch (error) { + if (savedDraftFiles) { dispatch({ - type: HAS_ERROR, - payload: errorSerializer(error), + type: event, + payload: { + parentRequestEventId, + files: savedDraftFiles, + }, }); - - dispatch(setTimelineInterval()); - - // throw it again, so it can be caught in the local state - throw error; } }; }; - -const _updatedState = (newComment, timelineState) => { - // return timeline with new comment - const timelineData = _cloneDeep(timelineState); - - // Multi-page: append to lastPageData - timelineData.lastPageHits.push(newComment); - timelineData.totalHits += 1; - - return timelineData; -}; 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 deleted file mode 100644 index a3668d04..00000000 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEditor/state/reducer.js +++ /dev/null @@ -1,48 +0,0 @@ -// This file is part of InvenioRequests -// Copyright (C) 2022-2025 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, - PARENT_RESTORE_DRAFT_CONTENT, - PARENT_SET_DRAFT_CONTENT, -} from "./actions"; - -const initialState = { - error: null, - isLoading: false, - commentContent: "", - storedCommentContent: null, -}; - -export const commentEditorReducer = (state = initialState, action) => { - switch (action.type) { - case PARENT_SET_DRAFT_CONTENT: - return { ...state, commentContent: action.payload.content }; - case IS_LOADING: - return { ...state, isLoading: true }; - case HAS_ERROR: - return { ...state, error: action.payload, isLoading: false }; - case SUCCESS: - return { - ...state, - isLoading: false, - error: null, - commentContent: "", - }; - case PARENT_RESTORE_DRAFT_CONTENT: - return { - ...state, - commentContent: action.payload.content, - // We'll never change this later, so it can be used as an `initialValue` - storedCommentContent: action.payload.content, - }; - default: - return state; - } -}; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/TimelineCommentEventControlled.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/TimelineCommentEventControlled.js index f6ade110..e95c3680 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/TimelineCommentEventControlled.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/TimelineCommentEventControlled.js @@ -26,7 +26,7 @@ class TimelineCommentEventControlled extends Component { this.setState({ isEditing: !isEditing, error: null }); }; - updateComment = async (content, format) => { + updateComment = async (content, format, files) => { const { updateComment, event } = this.props; if (!content) return; @@ -36,7 +36,7 @@ class TimelineCommentEventControlled extends Component { }); try { - await updateComment({ content, format, requestEventData: event }); + await updateComment({ content, format, files, requestEventData: event }); this.setState({ isLoading: false, @@ -67,6 +67,8 @@ class TimelineCommentEventControlled extends Component { allowQuoteReply, allowCopyLink, allowReply, + request, + isBeforeLoadMore, } = this.props; const { isLoading, isEditing, error } = this.state; @@ -87,6 +89,8 @@ class TimelineCommentEventControlled extends Component { allowQuoteReply={allowQuoteReply} allowCopyLink={allowCopyLink} allowReply={allowReply} + request={request} + isBeforeLoadMore={isBeforeLoadMore} /> ); @@ -104,6 +108,8 @@ TimelineCommentEventControlled.propTypes = { allowQuoteReply: PropTypes.bool, allowCopyLink: PropTypes.bool, allowReply: PropTypes.bool, + request: PropTypes.object.isRequired, + isBeforeLoadMore: PropTypes.bool.isRequired, }; TimelineCommentEventControlled.defaultProps = { diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/index.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/index.js deleted file mode 100644 index e1d1e3cc..00000000 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/index.js +++ /dev/null @@ -1,41 +0,0 @@ -// This file is part of InvenioRequests -// Copyright (C) 2022 CERN. -// -// 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 EventWithStateComponent from "./TimelineCommentEventControlled"; -import { connect } from "react-redux"; -import { updateComment, deleteComment } from "./state/actions"; -import { - IS_REFRESHING, - PARENT_DELETED_COMMENT, - PARENT_UPDATED_COMMENT, -} from "../timeline/state/actions"; -import { appendEventContent } from "../timelineCommentReplies/state/actions"; - -const mapDispatchToProps = (dispatch, ownProps) => ({ - updateComment: async (payload) => - dispatch( - updateComment({ - ...payload, - successEvent: PARENT_UPDATED_COMMENT, - loadingEvent: IS_REFRESHING, - }) - ), - deleteComment: async (payload) => - dispatch( - deleteComment({ - ...payload, - successEvent: PARENT_DELETED_COMMENT, - loadingEvent: IS_REFRESHING, - }) - ), - appendCommentContent: (content) => - dispatch(appendEventContent(ownProps.event.id, content)), -}); - -export const TimelineCommentEventControlled = connect( - null, - mapDispatchToProps -)(EventWithStateComponent); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/state/actions.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/state/actions.js index 9b09707a..e86bed95 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/state/actions.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentEventControlled/state/actions.js @@ -4,12 +4,13 @@ // 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. -import { clearTimelineInterval } from "../../timeline/state/actions"; +import { clearTimelineInterval } from "../../timelineParent/state/actions"; import { payloadSerializer } from "../../api/serializers"; export const updateComment = ({ content, format, + files, parentRequestEventId, requestEventData, successEvent, @@ -19,12 +20,12 @@ export const updateComment = ({ dispatch(clearTimelineInterval()); const commentsApi = config.requestEventsApi(requestEventData.links); - const payload = payloadSerializer(content, format); + const payload = payloadSerializer(content, format, files); dispatch({ type: loadingEvent, payload: { - parentRequestEventId: parentRequestEventId, + parentRequestEventId, }, }); @@ -34,7 +35,7 @@ export const updateComment = ({ type: successEvent, payload: { updatedComment: response.data, - parentRequestEventId: parentRequestEventId, + parentRequestEventId, }, }); @@ -55,7 +56,7 @@ export const deleteComment = ({ dispatch({ type: loadingEvent, payload: { - parentRequestEventId: parentRequestEventId, + parentRequestEventId, }, }); @@ -65,7 +66,7 @@ export const deleteComment = ({ type: successEvent, payload: { deletedCommentId: requestEventData.id, - parentRequestEventId: parentRequestEventId, + parentRequestEventId, }, }); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/TimelineCommentReplies.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/TimelineCommentReplies.js deleted file mode 100644 index d95cc126..00000000 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/TimelineCommentReplies.js +++ /dev/null @@ -1,232 +0,0 @@ -// This file is part of InvenioRequests -// Copyright (C) 2025 CERN. -// -// 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. - -import Overridable from "react-overridable"; -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import { Button, Divider, Icon } from "semantic-ui-react"; -import FakeInput from "../components/FakeInput"; -import { i18next } from "@translations/invenio_requests/i18next"; -import TimelineCommentEditor from "../timelineCommentEditor/TimelineCommentEditor"; -import TimelineCommentEventControlled from "../timelineCommentEventControlled/TimelineCommentEventControlled.js"; -import { DeleteConfirmationModal } from "../components/modals/DeleteConfirmationModal"; - -class TimelineCommentReplies extends Component { - constructor() { - super(); - this.state = { - isExpanded: true, - deleteModalAction: undefined, - }; - } - - componentDidMount() { - const { setInitialReplies, parentRequestEvent } = this.props; - setInitialReplies(parentRequestEvent); - } - - onRepliesClick = () => { - const { isExpanded } = this.state; - this.setState({ isExpanded: !isExpanded }); - }; - - onFakeInputActivate = (value) => { - const { setIsReplying, parentRequestEvent } = this.props; - setIsReplying(parentRequestEvent.id, true); - }; - - restoreCommentContent = () => { - const { restoreCommentContent, parentRequestEvent } = this.props; - restoreCommentContent(parentRequestEvent.id); - }; - - setCommentContent = (content) => { - const { setCommentContent, parentRequestEvent } = this.props; - setCommentContent(content, parentRequestEvent.id); - }; - - appendCommentContent = (content) => { - const { appendCommentContent, parentRequestEvent, setIsReplying } = this.props; - setIsReplying(parentRequestEvent.id, true); - appendCommentContent(content, parentRequestEvent.id); - }; - - submitReply = (content, format) => { - const { submitReply, parentRequestEvent } = this.props; - submitReply(parentRequestEvent, content, format); - }; - - onLoadMoreClick = () => { - const { loadOlderReplies, parentRequestEvent } = this.props; - loadOlderReplies(parentRequestEvent); - }; - - onDeleteModalOpen = (action) => { - this.setState({ deleteModalAction: action }); - }; - - deleteComment = (payload) => { - const { deleteComment, parentRequestEvent } = this.props; - deleteComment(payload, parentRequestEvent.id); - }; - - updateComment = (payload) => { - const { updateComment, parentRequestEvent } = this.props; - updateComment(payload, parentRequestEvent.id); - }; - - onCancelClick = () => { - const { clearDraft, parentRequestEvent, setIsReplying } = this.props; - setIsReplying(parentRequestEvent.id, false); - clearDraft(parentRequestEvent); - }; - - render() { - const { - commentReplies, - userAvatar, - draftContent, - storedDraftContent, - appendedDraftContent, - totalReplyCount, - submitting, - error, - hasMore, - loading, - isReplying, - pageSize, - allowReply, - } = this.props; - const { isExpanded, deleteModalAction } = this.state; - const hasReplies = totalReplyCount > 0; - - const notYetLoadedCommentCount = totalReplyCount - commentReplies.length; - const nextLoadSize = - notYetLoadedCommentCount >= pageSize ? pageSize : notYetLoadedCommentCount; - - return ( -
- {hasReplies && ( - <> - - - {(isExpanded || isReplying) && ( -
- {hasMore && ( - - )} - {commentReplies.map((c) => ( - - ))} - -
- )} - - )} - - {}} - onClose={() => this.setState({ deleteModalAction: undefined })} - /> - - {!isReplying ? ( - - ) : ( - - )} -
- ); - } -} - -TimelineCommentReplies.propTypes = { - commentReplies: PropTypes.array.isRequired, - parentRequestEvent: PropTypes.object.isRequired, - loadOlderReplies: PropTypes.func.isRequired, - userAvatar: PropTypes.string, - setCommentContent: PropTypes.func.isRequired, - restoreCommentContent: PropTypes.func.isRequired, - appendCommentContent: PropTypes.func.isRequired, - submitting: PropTypes.bool.isRequired, - error: PropTypes.string, - draftContent: PropTypes.string.isRequired, - storedDraftContent: PropTypes.string.isRequired, - appendedDraftContent: PropTypes.string.isRequired, - submitReply: PropTypes.func.isRequired, - setInitialReplies: PropTypes.func.isRequired, - hasMore: PropTypes.bool.isRequired, - updateComment: PropTypes.func.isRequired, - deleteComment: PropTypes.func.isRequired, - clearDraft: PropTypes.func.isRequired, - loading: PropTypes.bool.isRequired, - totalReplyCount: PropTypes.number.isRequired, - isReplying: PropTypes.bool.isRequired, - setIsReplying: PropTypes.func.isRequired, - pageSize: PropTypes.number.isRequired, - allowReply: PropTypes.bool.isRequired, -}; - -TimelineCommentReplies.defaultProps = { - userAvatar: "", - error: null, -}; - -export default Overridable.component( - "InvenioRequests.Timeline.CommentReplies", - TimelineCommentReplies -); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/TimelineFeedReplies.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/TimelineFeedReplies.js new file mode 100644 index 00000000..14398f5b --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/TimelineFeedReplies.js @@ -0,0 +1,28 @@ +import React, { Component } from "react"; +import TimelineFeedComponent from "../timeline/TimelineFeed"; +import PropTypes from "prop-types"; +import { getEventIdFromUrl } from "../timelineEvents/utils"; +import { DatasetContext } from "../data"; + +class TimelineFeedReplies extends Component { + componentDidMount() { + const { setInitialReplies } = this.props; + setInitialReplies(getEventIdFromUrl()); + } + + static contextType = DatasetContext; + + render() { + const { ...props } = this.props; + const { + defaultReplyQueryParams: { size }, + } = this.context; + return ; + } +} + +TimelineFeedReplies.propTypes = { + setInitialReplies: PropTypes.func.isRequired, +}; + +export default TimelineFeedReplies; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/index.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/index.js index 5c64a9ca..1333a849 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/index.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/index.js @@ -1,73 +1,112 @@ +// This file is part of InvenioRequests +// Copyright (C) 2026 CERN. +// +// 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 { connect } from "react-redux"; -import TimelineCommentRepliesComponent from "./TimelineCommentReplies.js"; import { selectCommentReplies, selectCommentRepliesStatus } from "./state/reducer.js"; import { appendEventContent, clearDraft, - IS_SUBMITTING, - loadOlderReplies, - REPLY_DELETE_COMMENT, + fetchRepliesPage, + REPLY_DELETED_COMMENT, REPLY_RESTORE_DRAFT_CONTENT, REPLY_SET_DRAFT_CONTENT, - REPLY_UPDATE_COMMENT, + REPLY_SET_DRAFT_FILES, + REPLY_RESTORE_DRAFT_FILES, + REPLY_UPDATED_COMMENT, + SET_SUBMITTING, setInitialReplies, setIsReplying, submitReply, } from "./state/actions.js"; import { - restoreEventContent, - setEventContent, + restoreDraftContent, + setDraftContent, + restoreEventFiles, + setEventFiles, } from "../timelineCommentEditor/state/actions.js"; import { deleteComment, updateComment, } from "../timelineCommentEventControlled/state/actions.js"; +import TimelineFeedRepliesComponent from "./TimelineFeedReplies.js"; + +const mapDispatchToProps = (dispatch, { parentRequestEvent }) => ({ + fetchPage: (page) => dispatch(fetchRepliesPage(parentRequestEvent, page)), + setCommentContent: (content) => + dispatch(setDraftContent(content, parentRequestEvent.id, REPLY_SET_DRAFT_CONTENT)), + restoreCommentContent: () => + dispatch(restoreDraftContent(parentRequestEvent.id, REPLY_RESTORE_DRAFT_CONTENT)), + submitComment: (content, format, files) => + dispatch(submitReply(parentRequestEvent, content, format, files)), + setCommentFiles: (files) => + dispatch(setEventFiles(files, parentRequestEvent.id, REPLY_SET_DRAFT_FILES)), + restoreCommentFiles: () => + dispatch(restoreEventFiles(parentRequestEvent.id, REPLY_RESTORE_DRAFT_FILES)), + updateComment: (payload) => + dispatch( + updateComment({ + ...payload, + parentRequestEventId: parentRequestEvent.id, + successEvent: REPLY_UPDATED_COMMENT, + loadingEvent: SET_SUBMITTING, + }) + ), + deleteComment: async (payload) => + dispatch( + deleteComment({ + ...payload, + parentRequestEventId: parentRequestEvent.id, + successEvent: REPLY_DELETED_COMMENT, + loadingEvent: SET_SUBMITTING, + }) + ), + appendCommentContent: (eventId, content) => + dispatch(appendEventContent(eventId, content)), + setInitialReplies: (focusEvent) => + dispatch(setInitialReplies(parentRequestEvent, focusEvent)), + setIsReplying: (replying) => dispatch(setIsReplying(parentRequestEvent.id, replying)), + clearDraft: () => dispatch(clearDraft(parentRequestEvent.id)), +}); + +const mapStateToProps = (state, { parentRequestEvent }) => { + const { + pageNumbers, + error, + submitting: isSubmitting, + draftContent: commentContent, + storedDraftContent: storedCommentContent, + draftFiles, + appendedDraftContent: appendedCommentContent, + submissionError, + totalHits, + replying, + warning, + } = selectCommentRepliesStatus(state.timelineReplies, parentRequestEvent.id); -const mapStateToProps = (state, ownProps) => { - const { parentRequestEvent } = ownProps; - const commentReplies = selectCommentReplies( - state.timelineReplies, - parentRequestEvent.id - ); - const status = selectCommentRepliesStatus( - state.timelineReplies, - parentRequestEvent.id - ); return { - commentReplies, - ...status, + hits: selectCommentReplies(state.timelineReplies, parentRequestEvent.id), + totalHits, + pageNumbers, + error, + isSubmitting, + permissions: parentRequestEvent.permissions, + initialLoading: false, + commentContent, + draftFiles, + storedCommentContent, + appendedCommentContent, + submissionError, + replying, + warning, }; }; -const mapDispatchToProps = { - loadOlderReplies, - setInitialReplies, - setIsReplying, - setCommentContent: (content, parentRequestEventId) => - setEventContent(content, parentRequestEventId, REPLY_SET_DRAFT_CONTENT), - restoreCommentContent: (parentRequestEventId) => - restoreEventContent(parentRequestEventId, REPLY_RESTORE_DRAFT_CONTENT), - appendCommentContent: (content, parentRequestEventId) => - appendEventContent(parentRequestEventId, content), - submitReply, - updateComment: (payload, parentRequestEventId) => - updateComment({ - ...payload, - parentRequestEventId, - successEvent: REPLY_UPDATE_COMMENT, - loadingEvent: IS_SUBMITTING, - }), - deleteComment: (payload, parentRequestEventId) => - deleteComment({ - ...payload, - parentRequestEventId, - successEvent: REPLY_DELETE_COMMENT, - loadingEvent: IS_SUBMITTING, - }), - clearDraft, -}; - -export const TimelineCommentReplies = connect( +const TimelineFeedReplies = connect( mapStateToProps, mapDispatchToProps -)(TimelineCommentRepliesComponent); +)(TimelineFeedRepliesComponent); + +export default TimelineFeedReplies; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/state/actions.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/state/actions.js index b1e92f4b..8597fcab 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/state/actions.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/state/actions.js @@ -7,24 +7,28 @@ import { errorSerializer, payloadSerializer } from "../../api/serializers"; import { deleteDraftComment, + deleteDraftFiles, setDraftComment, -} from "../../timelineCommentEditor/state/actions"; -import { selectCommentReplies, selectCommentRepliesStatus } from "./reducer"; - -export const IS_LOADING = "timelineReplies/IS_LOADING"; -export const IS_SUBMITTING = "timelineReplies/IS_SUBMITTING"; -export const IS_REPLYING = "timelineReplies/IS_REPLYING"; -export const IS_NOT_REPLYING = "timelineReplies/IS_NOT_REPLYING"; -export const HAS_NEW_DATA = "timelineReplies/HAS_DATA"; -export const IS_SUBMISSION_COMPLETE = "timelineReplies/IS_SUBMISSION_COMPLETE"; -export const HAS_ERROR = "timelineReplies/HAS_ERROR"; +} from "../../timelineCommentEditor/draftStorage"; +import { i18next } from "@translations/invenio_requests/i18next"; + +export const SET_LOADING = "timelineReplies/SET_LOADING"; +export const SET_TOTAL_HITS = "timelineReplies/SET_TOTAL_HITS"; +export const SET_SUBMITTING = "timelineReplies/SET_SUBMITTING"; +export const SET_REPLYING = "timelineReplies/SET_REPLYING"; export const SET_PAGE = "timelineReplies/SET_PAGE"; +export const APPEND_TO_PAGE = "timelineReplies/APPEND_TO_PAGE"; +export const HAS_ERROR = "timelineReplies/HAS_ERROR"; +export const SET_WARNING = "timelineReplies/SET_WARNING"; +export const HAS_SUBMISSION_ERROR = "timelineReplies/HAS_SUBMISSION_ERROR"; export const CLEAR_DRAFT = "timelineReplies/CLEAR_DRAFT"; export const REPLY_APPEND_DRAFT_CONTENT = "timelineReplies/APPEND_DRAFT_CONTENT"; export const REPLY_SET_DRAFT_CONTENT = "timelineReplies/SET_DRAFT_CONTENT"; export const REPLY_RESTORE_DRAFT_CONTENT = "timelineReplies/RESTORE_DRAFT_CONTENT"; -export const REPLY_UPDATE_COMMENT = "timelineReplies/UPDATE_COMMENT"; -export const REPLY_DELETE_COMMENT = "timelineReplies/DELETE_COMMENT"; +export const REPLY_SET_DRAFT_FILES = "timelineReplies/SET_DRAFT_FILES"; +export const REPLY_RESTORE_DRAFT_FILES = "timelineReplies/RESTORE_DRAFT_FILES"; +export const REPLY_UPDATED_COMMENT = "timelineReplies/UPDATED_COMMENT"; +export const REPLY_DELETED_COMMENT = "timelineReplies/DELETED_COMMENT"; export const appendEventContent = (parentRequestEventId, content) => { return (dispatch, getState) => { @@ -45,100 +49,159 @@ export const appendEventContent = (parentRequestEventId, content) => { }; }; -export const setIsReplying = (parentRequestEventId, isReplying) => { +export const setIsReplying = (parentRequestEventId, replying) => { return (dispatch) => { dispatch({ - type: isReplying ? IS_REPLYING : IS_NOT_REPLYING, + type: SET_REPLYING, payload: { parentRequestEventId, + replying, }, }); }; }; -export const setInitialReplies = (parentRequestEvent) => { - return (dispatch, _, config) => { +export const setInitialReplies = (parentRequestEvent, focusEvent) => { + return async (dispatch, _, config) => { // The server has the children newest-to-oldest, and we need oldest-to-newest so the newest is shown at the bottom. const children = (parentRequestEvent.children || []).toReversed(); const childrenCount = parentRequestEvent.children_count || 0; - // If we have children_count, check if there are more children than what's in the preview - // Otherwise, assume no more if children array is empty - const hasMore = childrenCount > children.length; - - const { defaultReplyQueryParams } = config ?? {}; - const pageSize = defaultReplyQueryParams.size ?? 5; + const { size: pageSize } = config.defaultReplyQueryParams; dispatch({ - type: HAS_NEW_DATA, + type: SET_WARNING, + payload: { parentRequestEventId: parentRequestEvent.id, warning: null }, + }); + dispatch({ + type: SET_PAGE, + payload: { + parentRequestEventId: parentRequestEvent.id, + hits: children, + page: 1, + }, + }); + dispatch({ + type: SET_TOTAL_HITS, payload: { - position: "top", parentRequestEventId: parentRequestEvent.id, - newChildComments: children, - hasMore: hasMore, - totalCount: childrenCount, - nextPage: 2, - pageSize, + totalHits: childrenCount, }, }); + + if ( + focusEvent && + focusEvent.parentEventId === parentRequestEvent.id && + focusEvent.replyEventId + ) { + // Check if focused event is on first or last page + const existsInPreview = children.some((h) => h.id === focusEvent.replyEventId); + + if (!existsInPreview) { + // Fetch focused event info to know which page it's on + const focusedPageResponse = await config + .requestEventsApi(parentRequestEvent.links) + .getRepliesFocused(focusEvent.replyEventId, { + size: pageSize, + sort: "newest", + }); + + if ( + focusedPageResponse.data.hits.hits.some( + (h) => h.id === focusEvent.replyEventId + ) + ) { + dispatch({ + type: SET_PAGE, + payload: { + parentRequestEventId: parentRequestEvent.id, + hits: focusedPageResponse.data.hits.hits.toReversed(), + page: focusedPageResponse.data.page, + }, + }); + } else { + // Show a warning if the event ID in the hash was not found in the response list of events. + // This happens if the server cannot find the requested event. + dispatch({ + type: SET_WARNING, + payload: { + parentRequestEventId: parentRequestEvent.id, + warning: i18next.t("We couldn't find the reply you were looking for."), + }, + }); + } + } + } }; }; -export const loadOlderReplies = (parentRequestEvent) => { - return async (dispatch, getState, config) => { - const { timelineReplies } = getState(); - const { page } = selectCommentRepliesStatus(timelineReplies, parentRequestEvent.id); - const commentReplies = selectCommentReplies(timelineReplies, parentRequestEvent.id); - const { defaultReplyQueryParams } = config ?? {}; - const pageSize = defaultReplyQueryParams.size ?? 5; +export const fetchRepliesPage = (parentRequestEvent, page) => { + return async (dispatch, _, config) => { + const { size: pageSize } = config.defaultReplyQueryParams; dispatch({ - type: IS_LOADING, - payload: { parentRequestEventId: parentRequestEvent.id }, + type: SET_LOADING, + payload: { parentRequestEventId: parentRequestEvent.id, loading: true }, }); - const api = config.requestEventsApi(parentRequestEvent.links); - const response = await api.getReplies({ - size: pageSize, - page, - sort: "newest", - }); + try { + const api = config.requestEventsApi(parentRequestEvent.links); + const response = await api.getReplies({ + size: pageSize, + page, + sort: "newest", + }); - const hits = response.data.hits.hits; - const totalLocalCommentCount = commentReplies.length + hits.length; - const hasMore = totalLocalCommentCount < response.data.hits.total; + dispatch({ + type: SET_PAGE, + payload: { + parentRequestEventId: parentRequestEvent.id, + // `hits` is ordered newest-to-oldest, which is correct for the pagination order. + // But we need to insert the comments oldest-to-newest in the UI. + hits: response.data.hits.hits.toReversed(), + page, + }, + }); - let nextPage = response.data.page; - if (hasMore) { - nextPage = response.data.page + 1; - } + dispatch({ + type: SET_TOTAL_HITS, + payload: { + parentRequestEventId: parentRequestEvent.id, + totalHits: response.data.hits.total, + }, + }); - dispatch({ - type: HAS_NEW_DATA, - payload: { - position: "top", - parentRequestEventId: parentRequestEvent.id, - hasMore, - // `hits` is ordered newest-to-oldest, which is correct for the pagination order. - // But we need to insert the comments oldest-to-newest in the UI. - newChildComments: hits.toReversed(), - nextPage: nextPage, - }, - }); + dispatch({ + type: SET_LOADING, + payload: { + parentRequestEventId: parentRequestEvent.id, + loading: false, + }, + }); + } catch (error) { + dispatch({ + type: HAS_ERROR, + payload: { + parentRequestEventId: parentRequestEvent.id, + error: errorSerializer(error), + }, + }); + } }; }; -export const submitReply = (parentRequestEvent, content, format) => { +export const submitReply = (parentRequestEvent, content, format, files) => { return async (dispatch, getState, config) => { const { request } = getState(); dispatch({ - type: IS_SUBMITTING, + type: SET_SUBMITTING, payload: { parentRequestEventId: parentRequestEvent.id, + submitting: true, }, }); - const payload = payloadSerializer(content, format || "html"); + const payload = payloadSerializer(content, format || "html", files); try { const response = await config @@ -147,29 +210,54 @@ export const submitReply = (parentRequestEvent, content, format) => { try { deleteDraftComment(request.data.id, parentRequestEvent.id); + deleteDraftFiles(request.data.id, parentRequestEvent.id); } catch (e) { console.warn("Failed to delete saved comment:", e); } - await dispatch({ - type: HAS_NEW_DATA, + dispatch({ + type: APPEND_TO_PAGE, + payload: { + parentRequestEventId: parentRequestEvent.id, + hits: [response.data], + page: 0, + }, + }); + + dispatch({ + type: SET_TOTAL_HITS, payload: { - position: "bottom", parentRequestEventId: parentRequestEvent.id, - newChildComments: [response.data], increaseCountBy: 1, }, }); dispatch({ - type: IS_SUBMISSION_COMPLETE, + type: SET_SUBMITTING, + payload: { + parentRequestEventId: parentRequestEvent.id, + submitting: false, + }, + }); + + dispatch({ + type: REPLY_SET_DRAFT_CONTENT, payload: { parentRequestEventId: parentRequestEvent.id, + content: "", + }, + }); + + dispatch({ + type: REPLY_SET_DRAFT_FILES, + payload: { + parentRequestEventId: parentRequestEvent.id, + files: [], }, }); } catch (error) { dispatch({ - type: HAS_ERROR, + type: HAS_SUBMISSION_ERROR, payload: { parentRequestEventId: parentRequestEvent.id, error: errorSerializer(error), @@ -181,14 +269,15 @@ export const submitReply = (parentRequestEvent, content, format) => { }; }; -export const clearDraft = (parentRequestEvent) => { +export const clearDraft = (parentRequestEventId) => { return (dispatch, getState) => { const { request } = getState(); - deleteDraftComment(request.data.id, parentRequestEvent.id); + deleteDraftComment(request.data.id, parentRequestEventId); + deleteDraftFiles(request.data.id, parentRequestEventId); dispatch({ type: CLEAR_DRAFT, payload: { - parentRequestEventId: parentRequestEvent.id, + parentRequestEventId, }, }); }; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/state/reducer.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/state/reducer.js index f99aeb12..2ad5cb05 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/state/reducer.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineCommentReplies/state/reducer.js @@ -5,52 +5,71 @@ // under the terms of the MIT License; see LICENSE file for more details. import { + APPEND_TO_PAGE, CLEAR_DRAFT, HAS_ERROR, - HAS_NEW_DATA, - IS_LOADING, - IS_SUBMISSION_COMPLETE, - IS_SUBMITTING, + HAS_SUBMISSION_ERROR, REPLY_APPEND_DRAFT_CONTENT, - REPLY_DELETE_COMMENT, + REPLY_DELETED_COMMENT, REPLY_RESTORE_DRAFT_CONTENT, REPLY_SET_DRAFT_CONTENT, - REPLY_UPDATE_COMMENT, + REPLY_SET_DRAFT_FILES, + REPLY_RESTORE_DRAFT_FILES, + REPLY_UPDATED_COMMENT, + SET_LOADING, SET_PAGE, - IS_REPLYING, - IS_NOT_REPLYING, + SET_REPLYING, + SET_SUBMITTING, + SET_TOTAL_HITS, + SET_WARNING, } from "./actions"; import _cloneDeep from "lodash/cloneDeep"; +import { findEventPageAndIndex, newOrIncreasedTotalHits } from "../../state/utils.js"; // Store lists of child comments and status objects, both grouped by parent request event ID. // This follows Redux recommendations for a neater and more maintainable state shape: https://redux.js.org/usage/structuring-reducers/normalizing-state-shape#designing-a-normalized-state export const initialState = { + // { parent_event_id: { page_number: comment[] } } commentRepliesData: {}, + // { parent_event_id: status_obj (see below) } commentStatuses: {}, }; +/** + * @returns object { page_number: comment[] } + */ export const selectCommentReplies = (state, parentRequestEventId) => { const { commentRepliesData } = state; if (Object.prototype.hasOwnProperty.call(commentRepliesData, parentRequestEventId)) { return commentRepliesData[parentRequestEventId]; } else { - return []; + return {}; + } +}; + +export const newOrAppendedPage = (state, parentRequestEventId, page, hits) => { + const commentReplies = selectCommentReplies(state, parentRequestEventId); + if (Object.prototype.hasOwnProperty.call(commentReplies, page)) { + return [...commentReplies[page], ...hits]; + } else { + return hits; } }; // Initial value for a single item in `commentStatuses` const initialCommentStatus = { - totalReplyCount: 0, + pageNumbers: [], + totalHits: 0, loading: false, submitting: false, error: null, - page: 1, - pageSize: 5, - hasMore: false, + warning: null, + submissionError: null, draftContent: "", storedDraftContent: "", appendedDraftContent: "", - isReplying: false, + draftFiles: [], + replying: false, }; export const selectCommentRepliesStatus = (state, parentRequestEventId) => { @@ -66,15 +85,19 @@ export const selectCommentRepliesStatus = (state, parentRequestEventId) => { const newCommentRepliesWithUpdate = (childComments, updatedComment) => { const newChildComments = _cloneDeep(childComments); - const index = newChildComments.findIndex((c) => c.id === updatedComment.id); - newChildComments[index] = updatedComment; + const position = findEventPageAndIndex(newChildComments, updatedComment.id); + if (position === null) return newChildComments; + + newChildComments[position.pageNumber][position.indexInPage] = { + ...newChildComments[position.pageNumber][position.indexInPage], + ...updatedComment, + }; return newChildComments; }; const newCommentRepliesWithDelete = (childComments, deletedCommentId) => { - const deletedComment = childComments.find((c) => c.id === deletedCommentId); return newCommentRepliesWithUpdate(childComments, { - ...deletedComment, + id: deletedCommentId, type: "L", payload: { content: "comment was deleted", @@ -98,97 +121,92 @@ const newStateWithUpdatedStatus = (state, parentRequestEventId, newStatus) => { }; }; -/** - * Returns an object to include in an item of `commentStatuses`. - * Either sets `totalReplyCount` if `totalCount` is defined, or increases if `increaseCountBy` is defined - */ -const newOrIncreasedReplyCount = (state, payload) => { - if (payload.totalCount) { - return { totalReplyCount: payload.totalCount }; - } else if (payload.increaseCountBy) { - const status = selectCommentRepliesStatus(state, payload.parentRequestEventId); - return { totalReplyCount: status.totalReplyCount + payload.increaseCountBy }; - } - return {}; -}; - -/** - * Returns the new list of replies, with the new replies either prepended or - * appended depending on the value of `payload.position` - */ -const prependedOrAppendedCommentReplies = (state, payload) => { - const existingCommentReplies = selectCommentReplies( - state, - payload.parentRequestEventId - ); - - if (payload.position === "top") { - return [ - // Prepend the new comments so they're shown at the top of the list. - ...payload.newChildComments, - ...existingCommentReplies, - ]; - } else { - return [ - ...existingCommentReplies, - // Append the new comments since they are newer - ...payload.newChildComments, - ]; - } +const newPageNumbersWithPage = (state, parentRequestEventId, page) => { + return [ + // Pass through a set to ensure de-duplication + ...new Set(selectCommentRepliesStatus(state, parentRequestEventId).pageNumbers).add( + page + ), + ].toSorted((a, b) => a - b); }; export const timelineRepliesReducer = (state = initialState, action) => { switch (action.type) { - case IS_LOADING: + case SET_LOADING: return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { - loading: true, + loading: action.payload.loading, error: null, }); - case IS_SUBMITTING: - return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { - submitting: true, - }); - case IS_SUBMISSION_COMPLETE: + case SET_SUBMITTING: return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { - submitting: false, - draftContent: "", + submitting: action.payload.submitting, + submissionError: null, }); - case IS_REPLYING: + case SET_TOTAL_HITS: + return newStateWithUpdatedStatus( + state, + action.payload.parentRequestEventId, + newOrIncreasedTotalHits( + selectCommentRepliesStatus(state, action.payload.parentRequestEventId), + action.payload + ) + ); + case SET_REPLYING: return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { - isReplying: true, + replying: action.payload.replying, }); - case IS_NOT_REPLYING: - return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { - isReplying: false, - }); - case HAS_NEW_DATA: + case SET_PAGE: return { ...newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { - loading: false, - error: null, - hasMore: action.payload.hasMore, - page: action.payload.nextPage, - // Don't set if not specified - ...(action.payload.pageSize ? { pageSize: action.payload.pageSize } : {}), - ...newOrIncreasedReplyCount(state, action.payload), + pageNumbers: newPageNumbersWithPage( + state, + action.payload.parentRequestEventId, + action.payload.page + ), }), commentRepliesData: { ...state.commentRepliesData, - [action.payload.parentRequestEventId]: prependedOrAppendedCommentReplies( + [action.payload.parentRequestEventId]: { + ...selectCommentReplies(state, action.payload.parentRequestEventId), + [action.payload.page]: action.payload.hits, + }, + }, + }; + case SET_WARNING: + return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { + warning: action.payload.warning, + }); + case APPEND_TO_PAGE: + return { + ...newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { + pageNumbers: newPageNumbersWithPage( state, - action.payload + action.payload.parentRequestEventId, + action.payload.page ), + }), + commentRepliesData: { + ...state.commentRepliesData, + [action.payload.parentRequestEventId]: { + ...selectCommentReplies(state, action.payload.parentRequestEventId), + [action.payload.page]: newOrAppendedPage( + state, + action.payload.parentRequestEventId, + action.payload.page, + action.payload.hits + ), + }, }, }; case HAS_ERROR: return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { error: action.payload.error, loading: false, - submitting: false, }); - case SET_PAGE: + case HAS_SUBMISSION_ERROR: return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { - page: action.payload.page, + submissionError: action.payload.error, + submitting: false, }); case REPLY_SET_DRAFT_CONTENT: return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { @@ -209,14 +227,23 @@ export const timelineRepliesReducer = (state = initialState, action) => { appendedDraftContent: selectCommentRepliesStatus(state, action.payload.parentRequestEventId) .draftContent + action.payload.content, - isReplying: true, + replying: true, + }); + case REPLY_SET_DRAFT_FILES: + return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { + draftFiles: action.payload.files, + }); + case REPLY_RESTORE_DRAFT_FILES: + return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { + draftFiles: action.payload.files, }); case CLEAR_DRAFT: return newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { draftContent: "", storedDraftContent: "", + draftFiles: [], }); - case REPLY_UPDATE_COMMENT: + case REPLY_UPDATED_COMMENT: return { ...newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { submitting: false, @@ -229,7 +256,7 @@ export const timelineRepliesReducer = (state = initialState, action) => { ), }, }; - case REPLY_DELETE_COMMENT: + case REPLY_DELETED_COMMENT: return { ...newStateWithUpdatedStatus(state, action.payload.parentRequestEventId, { submitting: false, 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 3dbf56e6..1b9ef0e4 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 @@ -14,11 +14,12 @@ import { CancelButton, SaveButton } from "../components/Buttons"; import Error from "../components/Error"; import { RichEditor } from "react-invenio-forms"; import RequestsFeed from "../components/RequestsFeed"; -import { TimelineEventBody } from "../components/TimelineEventBody"; +import TimelineEventBody from "../components/TimelineEventBody"; import { toRelativeTime } from "react-invenio-forms"; import { isEventSelected } from "./utils"; import { RequestEventsLinksExtractor } from "../api/InvenioRequestEventsApi.js"; -import { TimelineCommentReplies } from "../timelineCommentReplies/index.js"; +import { InvenioRequestFilesApi } from "../api/InvenioRequestFilesApi.js"; +import TimelineFeedReplies from "../timelineCommentReplies/index.js"; class TimelineCommentEvent extends Component { constructor(props) { @@ -28,6 +29,7 @@ class TimelineCommentEvent extends Component { this.state = { commentContent: event?.payload?.content, + files: event?.expanded?.files || [], isSelected: false, }; this.ref = createRef(null); @@ -86,6 +88,12 @@ class TimelineCommentEvent extends Component { appendCommentContent(`
${text}

`); }; + onFileUpload = async (filename, payload, options) => { + const client = new InvenioRequestFilesApi(); + const { request } = this.props; + return await client.uploadFile(request.id, filename, payload, options); + }; + render() { const { isLoading, @@ -99,9 +107,10 @@ class TimelineCommentEvent extends Component { isReply, allowQuoteReply, allowCopyLink, - allowReply, + request, + isBeforeLoadMore, } = this.props; - const { commentContent, isSelected } = this.state; + const { commentContent, files, isSelected } = this.state; const commentHasBeenDeleted = event?.payload?.event === "comment_deleted"; @@ -111,6 +120,7 @@ class TimelineCommentEvent extends Component { const canDelete = event?.permissions?.can_delete_comment; const canUpdate = event?.permissions?.can_update_comment; + const canReply = event?.permissions?.can_reply_comment; const createdBy = event.created_by; const isUser = "user" in createdBy; @@ -125,6 +135,7 @@ class TimelineCommentEvent extends Component { as={Image} circular hasLine={isReply} + lineFade={isBeforeLoadMore} /> ); userName = expandedCreatedBy.profile?.full_name || expandedCreatedBy.username; @@ -204,11 +215,17 @@ class TimelineCommentEvent extends Component { onEditorChange={(event, editor) => { this.setState({ commentContent: editor.getContent() }); }} + files={files} + onFilesChange={(files) => { + this.setState({ files: files }); + }} + // This is an existing comment, so we do not delete the file via the API. + onFileUpload={this.onFileUpload} minHeight={150} /> ) : ( )} @@ -217,7 +234,7 @@ class TimelineCommentEvent extends Component { toggleEditMode()} /> updateComment(commentContent, "html")} + onClick={() => updateComment(commentContent, "html", files)} loading={isLoading} /> @@ -226,16 +243,17 @@ class TimelineCommentEvent extends Component { {commentHasBeenEdited && {i18next.t("Edited")}} - {!isReply && ( + {!isReply ? ( <> - - )} + ) : null} @@ -246,6 +264,7 @@ class TimelineCommentEvent extends Component { TimelineCommentEvent.propTypes = { event: PropTypes.object.isRequired, + request: PropTypes.object.isRequired, deleteComment: PropTypes.func.isRequired, updateComment: PropTypes.func.isRequired, appendCommentContent: PropTypes.func.isRequired, @@ -255,9 +274,9 @@ TimelineCommentEvent.propTypes = { error: PropTypes.string, userAvatar: PropTypes.string, isReply: PropTypes.bool, + isBeforeLoadMore: PropTypes.bool.isRequired, allowQuoteReply: PropTypes.bool, allowCopyLink: PropTypes.bool, - allowReply: PropTypes.bool, }; TimelineCommentEvent.defaultProps = { @@ -268,7 +287,6 @@ TimelineCommentEvent.defaultProps = { isReply: false, allowQuoteReply: true, allowCopyLink: true, - allowReply: true, }; export default Overridable.component("TimelineEvent", TimelineCommentEvent); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/utils.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/utils.js index f3196be5..ec08c08b 100644 --- a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/utils.js +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/utils.js @@ -6,6 +6,8 @@ import { RequestEventsLinksExtractor } from "../api/InvenioRequestEventsApi"; +const COMMENT_PREFIX = "#commentevent-"; + export const isEventSelected = (event) => { const eventUrl = new URL(new RequestEventsLinksExtractor(event.links).eventHtmlUrl); const currentUrl = new URL(window.location.href); @@ -15,10 +17,18 @@ export const isEventSelected = (event) => { export const getEventIdFromUrl = () => { const currentUrl = new URL(window.location.href); const hash = currentUrl.hash; - let eventId = null; - const commentPrefix = "#commentevent-"; - if (hash.startsWith(commentPrefix)) { - eventId = hash.substring(commentPrefix.length); + let parentEventId = null; + let replyEventId = null; + + if (hash.startsWith(COMMENT_PREFIX)) { + let ids = hash.substring(COMMENT_PREFIX.length); + ids = ids.split("_"); + parentEventId = ids[0]; + if (ids.length === 2) { + replyEventId = ids[1]; + } + return { parentEventId, replyEventId }; } - return eventId; + + return null; }; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/TimelineFeedParent.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/TimelineFeedParent.js new file mode 100644 index 00000000..2eab0452 --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/TimelineFeedParent.js @@ -0,0 +1,34 @@ +import React, { Component } from "react"; +import TimelineFeedComponent from "../timeline/TimelineFeed"; +import PropTypes from "prop-types"; +import { getEventIdFromUrl } from "../timelineEvents/utils"; +import { DatasetContext } from "../data"; + +class TimelineFeedParent extends Component { + componentDidMount() { + const { getTimelineWithRefresh } = this.props; + getTimelineWithRefresh(getEventIdFromUrl()); + } + + componentWillUnmount() { + const { timelineStopRefresh } = this.props; + timelineStopRefresh(); + } + + static contextType = DatasetContext; + + render() { + const { getTimelineWithRefresh: _, timelineStopRefresh: __, ...props } = this.props; + const { + defaultQueryParams: { size }, + } = this.context; + return ; + } +} + +TimelineFeedParent.propTypes = { + getTimelineWithRefresh: PropTypes.func.isRequired, + timelineStopRefresh: PropTypes.func.isRequired, +}; + +export default TimelineFeedParent; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/index.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/index.js new file mode 100644 index 00000000..9bcc2e47 --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/index.js @@ -0,0 +1,88 @@ +// This file is part of InvenioRequests +// Copyright (C) 2026 CERN. +// +// 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 { connect } from "react-redux"; +import { + getTimelineWithRefresh, + clearTimelineInterval, + PARENT_SET_DRAFT_CONTENT, + PARENT_RESTORE_DRAFT_CONTENT, + PARENT_SET_DRAFT_FILES, + PARENT_RESTORE_DRAFT_FILES, + submitComment, + PARENT_UPDATED_COMMENT, + SET_REFRESHING, + PARENT_DELETED_COMMENT, + fetchTimelinePageWithLoading, +} from "./state/actions"; +import TimelineFeedParent from "./TimelineFeedParent"; +import { + restoreDraftContent, + setDraftContent, + setEventFiles, + restoreEventFiles, +} from "../timelineCommentEditor/state/actions"; +import { + deleteComment, + updateComment, +} from "../timelineCommentEventControlled/state/actions"; +import { appendEventContent } from "../timelineCommentReplies/state/actions"; + +const mapDispatchToProps = (dispatch) => ({ + getTimelineWithRefresh: (includeEventId) => + dispatch(getTimelineWithRefresh(includeEventId)), + timelineStopRefresh: () => dispatch(clearTimelineInterval()), + fetchPage: (page) => dispatch(fetchTimelinePageWithLoading(page)), + setCommentContent: (content) => + dispatch(setDraftContent(content, null, PARENT_SET_DRAFT_CONTENT)), + restoreCommentContent: () => + dispatch(restoreDraftContent(null, PARENT_RESTORE_DRAFT_CONTENT)), + setCommentFiles: (files) => + dispatch(setEventFiles(files, null, PARENT_SET_DRAFT_FILES)), + restoreCommentFiles: () => + dispatch(restoreEventFiles(null, PARENT_RESTORE_DRAFT_FILES)), + submitComment: (content, format, files) => + dispatch(submitComment(content, format, files)), + updateComment: async (payload) => + dispatch( + updateComment({ + ...payload, + successEvent: PARENT_UPDATED_COMMENT, + loadingEvent: SET_REFRESHING, + }) + ), + deleteComment: async (payload) => + dispatch( + deleteComment({ + ...payload, + successEvent: PARENT_DELETED_COMMENT, + loadingEvent: SET_REFRESHING, + }) + ), + appendCommentContent: (eventId, content) => + dispatch(appendEventContent(eventId, content)), +}); + +const mapStateToProps = (state) => { + return { + hits: state.timeline.hits, + totalHits: state.timeline.totalHits, + pageNumbers: state.timeline.pageNumbers, + initialLoading: state.timeline.initialLoading, + error: state.timeline.error, + isSubmitting: state.timeline.submitting, + warning: state.timeline.warning, + commentContent: state.timeline.commentContent, + storedCommentContent: state.timeline.storedCommentContent, + draftFiles: state.timeline.files, + submissionError: state.timeline.submissionError, + }; +}; + +export const Timeline = connect( + mapStateToProps, + mapDispatchToProps +)(TimelineFeedParent); diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/state/actions.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/state/actions.js new file mode 100644 index 00000000..dbeb7505 --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/state/actions.js @@ -0,0 +1,325 @@ +// This file is part of InvenioRequests +// Copyright (C) 2022 CERN. +// +// 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 { errorSerializer, payloadSerializer } from "../../api/serializers"; +import { + deleteDraftComment, + deleteDraftFiles, +} from "../../timelineCommentEditor/draftStorage.js"; +import { i18next } from "@translations/invenio_requests/i18next"; + +export const SET_INITIAL_LOADING = "timeline/SET_LOADING"; +export const HAS_ERROR = "timeline/HAS_ERROR"; +export const HAS_SUBMISSION_ERROR = "timeline/HAS_SUBMISSION_ERROR"; +export const SET_REFRESHING = "timeline/SET_REFRESHING"; +export const SET_SUBMITTING = "timeline/SET_SUBMITTING"; +export const SET_TOTAL_HITS = "timeline/SET_TOTAL_HITS"; +export const SET_FOCUSED_PAGE = "timeline/SET_FOCUSED_PAGE"; +export const SET_LOADING_MORE = "timeline/SET_LOADING_MORE"; +export const SET_WARNING = "timeline/SET_WARNING"; +export const SET_PAGE = "timeline/SET_PAGE"; +export const APPEND_TO_LAST_PAGE = "timeline/APPEND_TO_LAST_PAGE"; +export const PARENT_UPDATED_COMMENT = "timeline/PARENT_UPDATED_COMMENT"; +export const PARENT_DELETED_COMMENT = "timeline/PARENT_DELETED_COMMENT"; +export const PARENT_SET_DRAFT_CONTENT = "timeline/PARENT_SET_DRAFT_CONTENT"; +export const PARENT_RESTORE_DRAFT_CONTENT = "timeline/PARENT_RESTORE_DRAFT_CONTENT"; +export const PARENT_SET_DRAFT_FILES = "timeline/PARENT_SET_DRAFT_FILES"; +export const PARENT_RESTORE_DRAFT_FILES = "timeline/PARENT_RESTORE_DRAFT_FILES"; + +class intervalManager { + static IntervalId = undefined; + + static setIntervalId(intervalId) { + this.intervalId = intervalId; + } + + static resetInterval() { + clearInterval(this.intervalId); + delete this.intervalId; + } +} + +export const fetchTimeline = (focusEvent = undefined) => { + return async (dispatch, _, config) => { + const { size } = config.defaultQueryParams; + + dispatch({ type: HAS_ERROR, payload: null }); + dispatch({ type: SET_WARNING, payload: { warning: null } }); + + try { + const firstPageResponse = await config.requestsApi.getTimeline({ + size, + page: 1, + sort: "oldest", + }); + + const totalHits = firstPageResponse.data.hits.total || 0; + const lastPageNumber = Math.ceil(totalHits / size); + + let lastPageResponse = null; + // Only fetch last page if not the same as the first page + if (lastPageNumber > 1) { + lastPageResponse = await config.requestsApi.getTimeline({ + size, + page: lastPageNumber, + sort: "oldest", + }); + } + + let focusedPageNumber = null; + let focusedPageResponse = null; + + if (focusEvent) { + // Check if focused event is on first or last page + const existsOnFirstPage = firstPageResponse.data.hits.hits.some( + (h) => h.id === focusEvent.parentEventId + ); + const existsOnLastPage = lastPageResponse?.data.hits.hits.some( + (h) => h.id === focusEvent.parentEventId + ); + + if (existsOnFirstPage) { + focusedPageNumber = 1; + } else if (existsOnLastPage) { + focusedPageNumber = lastPageNumber; + } else { + // Fetch focused event info to know which page it's on + focusedPageResponse = await config.requestsApi.getTimelineFocused( + focusEvent.parentEventId, + { + size, + sort: "oldest", + } + ); + focusedPageNumber = focusedPageResponse?.data.page; + + if ( + !focusedPageResponse.data.hits.hits.some( + (h) => h.id === focusEvent.parentEventId + ) + ) { + // Show a warning if the event ID in the hash was not found in the response list of events. + // This happens if the server cannot find the requested event. + dispatch({ + type: SET_WARNING, + payload: { + warning: i18next.t( + "We couldn't find the comment you were looking for." + ), + }, + }); + } + } + } + + dispatch({ + type: SET_PAGE, + payload: { + page: 1, + hits: firstPageResponse.data.hits.hits, + }, + }); + + if (focusedPageResponse) { + dispatch({ + type: SET_PAGE, + payload: { + page: focusedPageNumber, + hits: focusedPageResponse.data.hits.hits, + }, + }); + } + + if (lastPageResponse) { + dispatch({ + type: SET_PAGE, + payload: { + page: lastPageNumber, + hits: lastPageResponse.data.hits.hits, + }, + }); + } + + dispatch({ + type: SET_TOTAL_HITS, + payload: { + totalHits, + }, + }); + + dispatch({ + type: SET_FOCUSED_PAGE, + payload: { + focusedPage: focusedPageNumber, + }, + }); + } catch (error) { + dispatch({ + type: HAS_ERROR, + payload: errorSerializer(error), + }); + } + }; +}; + +export const fetchTimelinePage = (page) => { + return async (dispatch, _, config) => { + const { size } = config.defaultQueryParams; + + try { + const response = await config.requestsApi.getTimeline({ + size, + page, + sort: "oldest", + }); + + dispatch({ + type: SET_PAGE, + payload: { + page, + hits: response.data.hits.hits, + }, + }); + } catch (error) { + dispatch({ + type: HAS_ERROR, + payload: errorSerializer(error), + }); + } + }; +}; + +export const fetchTimelinePageWithLoading = (page) => { + return async (dispatch) => { + dispatch({ type: SET_LOADING_MORE, payload: { loadingMore: true } }); + await dispatch(fetchTimelinePage(page)); + dispatch({ type: SET_LOADING_MORE, payload: { loadingMore: false } }); + }; +}; + +const timelineReload = async (dispatch, getState) => { + const { timeline } = getState(); + const { initialLoading, lastPageRefreshing, error, submitting, pageNumbers } = + timeline; + + if (error) { + dispatch(clearTimelineInterval()); + } + + const concurrentRequests = initialLoading || lastPageRefreshing || submitting; + if (concurrentRequests) return; + + const lastPage = pageNumbers[pageNumbers.length - 1]; + + // Fetch only the last page + dispatch({ type: SET_REFRESHING, payload: { refreshing: true } }); + await dispatch(fetchTimelinePage(lastPage)); + dispatch({ type: SET_REFRESHING, payload: { refreshing: false } }); +}; + +export const getTimelineWithRefresh = (focusEventId) => { + return async (dispatch) => { + dispatch({ + type: SET_INITIAL_LOADING, + payload: { loading: true }, + }); + // Fetch both first and last pages + await dispatch(fetchTimeline(focusEventId)); + dispatch(setTimelineInterval()); + dispatch({ + type: SET_INITIAL_LOADING, + payload: { loading: false }, + }); + }; +}; + +export const setTimelineInterval = () => { + return async (dispatch, getState, config) => { + const intervalAlreadySet = intervalManager.intervalId; + + if (!intervalAlreadySet) { + const intervalId = setInterval( + () => timelineReload(dispatch, getState, config), + config.refreshIntervalMs + ); + intervalManager.setIntervalId(intervalId); + } + }; +}; + +export const clearTimelineInterval = () => { + return () => { + intervalManager.resetInterval(); + }; +}; + +export const submitComment = (content, format, files) => { + return async (dispatch, getState, config) => { + const { request } = getState(); + const { size } = config.defaultQueryParams; + + dispatch(clearTimelineInterval()); + + dispatch({ + type: SET_SUBMITTING, + payload: { submitting: true }, + }); + + const payload = payloadSerializer(content, format || "html", files); + + try { + /* Because of the delay in ES indexing we need to handle the updated state on the client-side until it is ready to be retrieved from the server.*/ + + const response = await config.requestsApi.submitComment(payload); + + try { + deleteDraftComment(request.data.id); + deleteDraftFiles(request.data.id); + } catch (e) { + console.warn("Failed to delete saved comment:", e); + } + + dispatch({ + type: APPEND_TO_LAST_PAGE, + payload: { + hit: response.data, + pageSize: size, + }, + }); + + dispatch({ + type: SET_TOTAL_HITS, + payload: { + increaseCountBy: 1, + }, + }); + + dispatch({ + type: SET_SUBMITTING, + payload: { submitting: false }, + }); + + dispatch({ + type: PARENT_SET_DRAFT_CONTENT, + payload: { content: "" }, + }); + + dispatch({ + type: PARENT_SET_DRAFT_FILES, + payload: { files: [] }, + }); + + dispatch(setTimelineInterval()); + } catch (error) { + dispatch({ + type: HAS_SUBMISSION_ERROR, + payload: errorSerializer(error), + }); + + dispatch(setTimelineInterval()); + } + }; +}; diff --git a/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/state/reducer.js b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/state/reducer.js new file mode 100644 index 00000000..ab630704 --- /dev/null +++ b/invenio_requests/assets/semantic-ui/js/invenio_requests/timelineParent/state/reducer.js @@ -0,0 +1,175 @@ +// This file is part of InvenioRequests +// Copyright (C) 2022 CERN. +// +// 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 { + HAS_ERROR, + SET_WARNING, + PARENT_DELETED_COMMENT, + PARENT_UPDATED_COMMENT, + SET_PAGE, + SET_TOTAL_HITS, + SET_FOCUSED_PAGE, + SET_LOADING_MORE, + APPEND_TO_LAST_PAGE, + PARENT_SET_DRAFT_CONTENT, + PARENT_RESTORE_DRAFT_CONTENT, + PARENT_SET_DRAFT_FILES, + PARENT_RESTORE_DRAFT_FILES, + HAS_SUBMISSION_ERROR, + SET_SUBMITTING, + SET_REFRESHING, + SET_INITIAL_LOADING, +} from "./actions"; +import _cloneDeep from "lodash/cloneDeep"; +import { findEventPageAndIndex, newOrIncreasedTotalHits } from "../../state/utils"; + +export const initialState = { + initialLoading: false, + lastPageRefreshing: false, + loadingMore: false, + // Dictionary of form { page_number: []hit } + hits: {}, + pageNumbers: [], + totalHits: 0, + error: null, + submissionError: null, + // The page number that the focused event belongs to. + focusedPage: null, + warning: null, + commentContent: "", + files: [], + storedCommentContent: null, + submitting: false, +}; + +const newStateWithUpdate = (timelineState, updatedComment) => { + const newTimelineState = _cloneDeep(timelineState); + const position = findEventPageAndIndex(newTimelineState["hits"], updatedComment.id); + if (position === null) return newTimelineState; + newTimelineState.hits[position.pageNumber][position.indexInPage] = { + ...newTimelineState.hits[position.pageNumber][position.indexInPage], + ...updatedComment, + }; + return newTimelineState; +}; + +const newStateWithDelete = (timelineState, deletedCommentId) => { + return newStateWithUpdate(timelineState, { + id: deletedCommentId, + type: "L", + payload: { + content: "comment was deleted", + format: "html", + event: "comment_deleted", + }, + }); +}; + +const appendToLastOrNewPage = (timelineState, hit, pageSize) => { + const lastPage = Math.max(...timelineState.pageNumbers); + const lastPageSize = timelineState.hits[lastPage].length; + + if (lastPageSize >= pageSize) { + return { + ...timelineState, + hits: { + ...timelineState.hits, + [lastPage + 1]: [hit], + }, + pageNumbers: [...timelineState.pageNumbers, lastPage + 1].toSorted(), + }; + } else { + return { + ...timelineState, + hits: { + ...timelineState.hits, + [lastPage]: [...timelineState.hits[lastPage], hit], + }, + }; + } +}; + +export const timelineReducer = (state = initialState, action) => { + switch (action.type) { + case SET_INITIAL_LOADING: + return { ...state, initialLoading: action.payload.loading }; + case SET_REFRESHING: + return { ...state, lastPageRefreshing: action.payload.refreshing }; + case SET_PAGE: + return { + ...state, + hits: { + ...state.hits, + [action.payload.page]: action.payload.hits, + }, + pageNumbers: [...new Set(state.pageNumbers).add(action.payload.page)].toSorted( + (a, b) => a - b + ), + }; + case APPEND_TO_LAST_PAGE: + return appendToLastOrNewPage(state, action.payload.hit, action.payload.pageSize); + case SET_TOTAL_HITS: + return { + ...state, + ...newOrIncreasedTotalHits(state, action.payload), + }; + case SET_FOCUSED_PAGE: + return { + ...state, + focusedPage: action.payload.focusedPage, + }; + case SET_LOADING_MORE: + return { + ...state, + loadingMore: action.payload.loadingMore, + }; + case SET_SUBMITTING: + return { + ...state, + submitting: action.payload.submitting, + submissionError: null, + }; + case HAS_ERROR: + return { + ...state, + error: action.payload, + }; + case HAS_SUBMISSION_ERROR: + return { + ...state, + submitting: false, + submissionError: action.payload, + }; + case SET_WARNING: + return { + ...state, + warning: action.payload.warning, + }; + case PARENT_UPDATED_COMMENT: + return newStateWithUpdate(state, action.payload.updatedComment); + case PARENT_DELETED_COMMENT: + return newStateWithDelete(state, action.payload.deletedCommentId); + case PARENT_SET_DRAFT_CONTENT: + return { ...state, commentContent: action.payload.content }; + case PARENT_RESTORE_DRAFT_CONTENT: + return { + ...state, + commentContent: action.payload.content, + // We'll never change this later, so it can be used as an `initialValue` + storedCommentContent: action.payload.content, + }; + case PARENT_SET_DRAFT_FILES: + return { ...state, files: action.payload.files }; + case PARENT_RESTORE_DRAFT_FILES: + return { + ...state, + files: action.payload.files, + }; + + default: + return state; + } +}; diff --git a/invenio_requests/config.py b/invenio_requests/config.py index a5082c52..f190859e 100644 --- a/invenio_requests/config.py +++ b/invenio_requests/config.py @@ -13,6 +13,10 @@ from invenio_users_resources.entity_resolvers import GroupResolver, UserResolver from invenio_requests.services.requests import facets +from invenio_requests.services.requests.components import ( + RequestCommentFileCleanupComponent, + RequestCommentFileValidationComponent, +) from .customizations import CommentEventType, LogEventType, ReviewersUpdatedType from .services.permissions import PermissionPolicy @@ -34,7 +38,7 @@ """Registered resolvers for resolving/creating references in request metadata.""" REQUESTS_ROUTES = { - "details": "/requests/", + "download_file_html": "/requests//files/", } """Invenio requests ui endpoints.""" @@ -143,3 +147,19 @@ This limits the size of indexed documents when comments have many replies. Additional replies can be loaded via pagination. """ + +REQUESTS_FILES_DEFAULT_QUOTA_SIZE = 100 * 10**6 # 100MB +REQUESTS_FILES_DEFAULT_MAX_FILE_SIZE = 10 * 10**6 # 10MB + +REQUESTS_EVENTS_SERVICE_COMPONENTS = [ + RequestCommentFileValidationComponent, + RequestCommentFileCleanupComponent, +] + +REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_TAGS = ["img"] +"""Extend allowed HTML tags list for requests comments content.""" + +REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_ATTRS = { + "img": ["src", "alt", "width", "height"], +} +"""Extend allowed HTML attrs list for requests comments content.""" diff --git a/invenio_requests/customizations/event_types.py b/invenio_requests/customizations/event_types.py index 3924707b..616f7a6e 100644 --- a/invenio_requests/customizations/event_types.py +++ b/invenio_requests/customizations/event_types.py @@ -8,15 +8,50 @@ """Base class for creating custom event types of requests.""" import inspect +from uuid import UUID import marshmallow as ma -from marshmallow import RAISE, fields, validate +from flask import current_app +from invenio_i18n import lazy_gettext as _ +from marshmallow import RAISE, Schema, ValidationError, fields, validate from marshmallow.validate import OneOf from marshmallow_utils import fields as utils_fields from ..proxies import current_requests +def is_uuid(value): + """Make sure value is a UUID.""" + try: + UUID(value) + except (ValueError, TypeError): + raise ValidationError( + _("The ID must be an Universally Unique IDentifier (UUID).") + ) + + +# FIXME: Ideally this should go to records-resources, with an extra argument specifying which config prefix to use. +class RequestsCommentsSanitizedHTML(utils_fields.SanitizedHTML): + """A subclass of SanitizedHTML that dynamically configures allowed HTML tags and attributes based on application settings.""" + + def __init__(self, *args, **kwargs): + """Initializes RequestsCommentsSanitizedHTML with dynamic tag and attribute settings.""" + super().__init__(tags=None, attrs=None, *args, **kwargs) + + def _deserialize(self, value, attr, data, **kwargs): + """Deserialize value with dynamic HTML tags and attributes based on Flask app context or defaults.""" + self.tags = ( + current_app.config.get("ALLOWED_HTML_TAGS", []) + + current_app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_TAGS"] + ) + self.attrs = self.attrs = dict( + **current_app.config.get("ALLOWED_HTML_ATTRS", {}), + **current_app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_ATTRS"], + ) + + return super()._deserialize(value, attr, data, **kwargs) + + class EventType: """Base class for event types.""" @@ -164,6 +199,16 @@ def payload_schema(): ) +class FileDetailsSchema(Schema): + """File details schema using entity reference format. + + Files are referenced as {"file": "uuid"} to be compatible with + the entity resolver pattern. + """ + + file_id = fields.String(validate=is_uuid, required=True) + + class CommentEventType(EventType): """Comment event type.""" @@ -178,13 +223,14 @@ def payload_schema(): from invenio_requests.records.api import RequestEventFormat return dict( - content=utils_fields.SanitizedHTML( + content=RequestsCommentsSanitizedHTML( required=True, validate=validate.Length(min=1) ), format=fields.Str( validate=validate.OneOf(choices=[e.value for e in RequestEventFormat]), load_default=RequestEventFormat.HTML.value, ), + files=fields.List(fields.Nested(FileDetailsSchema)), ) payload_required = True diff --git a/invenio_requests/customizations/request_types.py b/invenio_requests/customizations/request_types.py index d9d616d3..af7292e6 100644 --- a/invenio_requests/customizations/request_types.py +++ b/invenio_requests/customizations/request_types.py @@ -114,6 +114,9 @@ class RequestType: topic_can_be_none = True """Determines if the ``topic`` reference accepts ``None``.""" + files_can_be_none = True + """Determines if the ``files`` reference accepts ``None``.""" + allowed_creator_ref_types = ["user"] """A list of allowed TYPE keys for ``created_by`` reference dicts.""" @@ -143,6 +146,9 @@ def locking_enabled(cls): resolve_topic_needs = False """Whether to resolve needs for the topic entity.""" + allowed_files_ref_types = ["enabled"] + """A list of allowed TYPE keys for ``files`` reference dicts.""" + payload_schema = None payload_schema_cls = None """Schema for supported payload fields. @@ -218,6 +224,13 @@ def _create_marshmallow_schema(cls): RefBaseSchema.create_from_dict(cls.allowed_topic_ref_types), allow_none=cls.topic_can_be_none, ), + "files": ma.fields.Nested( + RefBaseSchema.create_from_dict( + cls.allowed_files_ref_types, + special_fields={"enabled": ma.fields.Boolean()}, + ), + allow_none=cls.files_can_be_none, + ), } if cls.reviewers_can_be_none(): diff --git a/invenio_requests/ext.py b/invenio_requests/ext.py index 293f7d74..2a050c2c 100644 --- a/invenio_requests/ext.py +++ b/invenio_requests/ext.py @@ -19,12 +19,16 @@ from .resources import ( RequestCommentsResource, RequestCommentsResourceConfig, + RequestFilesResource, + RequestFilesResourceConfig, RequestsResource, RequestsResourceConfig, ) from .services import ( RequestEventsService, RequestEventsServiceConfig, + RequestFilesService, + RequestFilesServiceConfig, RequestsService, RequestsServiceConfig, UserModerationRequestService, @@ -38,7 +42,8 @@ def __init__(self, app=None): """Extension initialization.""" self.requests_service = None self.requests_resource = None - self.request_comments_service = None + self.request_events_service = None + self.request_files_service = None self._schema_cache = {} self._events_schema_cache = {} if app: @@ -64,6 +69,7 @@ def service_configs(self, app): class ServiceConfigs: requests = RequestsServiceConfig.build(app) request_events = RequestEventsServiceConfig.build(app) + request_files = RequestFilesServiceConfig.build(app) return ServiceConfigs @@ -77,6 +83,9 @@ def init_services(self, app): self.request_events_service = RequestEventsService( config=service_configs.request_events, ) + self.request_files_service = RequestFilesService( + config=service_configs.request_files, + ) self.user_moderation_requests_service = UserModerationRequestService( requests_service=self.requests_service, ) @@ -93,6 +102,11 @@ def init_resources(self, app): config=RequestCommentsResourceConfig, ) + self.request_files_resource = RequestFilesResource( + service=self.request_files_service, + config=RequestFilesResourceConfig, + ) + def init_registry(self, app): """Initialize the registry for Requests per type.""" self.request_type_registry = TypeRegistry( @@ -148,9 +162,11 @@ def init(app): requests_ext = app.extensions["invenio-requests"] requests_service = requests_ext.requests_service events_service = requests_ext.request_events_service + files_service = requests_ext.request_files_service svc_reg.register(requests_service) svc_reg.register(events_service) + svc_reg.register(files_service) idx_reg.register(requests_service.indexer, indexer_id="requests") idx_reg.register(events_service.indexer, indexer_id="events") diff --git a/invenio_requests/proxies.py b/invenio_requests/proxies.py index 919a35a2..2acf4e0b 100644 --- a/invenio_requests/proxies.py +++ b/invenio_requests/proxies.py @@ -29,6 +29,11 @@ ) """Proxy to the instantiated requests service.""" +current_request_files_service = LocalProxy( + lambda: current_app.extensions["invenio-requests"].request_files_service +) +"""Proxy to the instantiated request files service.""" + current_events_service = LocalProxy( lambda: current_app.extensions["invenio-requests"].request_events_service ) diff --git a/invenio_requests/records/api.py b/invenio_requests/records/api.py index 69ec9226..8c30e82a 100644 --- a/invenio_requests/records/api.py +++ b/invenio_requests/records/api.py @@ -11,11 +11,15 @@ from enum import Enum from functools import partial +from flask import current_app +from invenio_db import db +from invenio_files_rest.models import ObjectVersion from invenio_records.dumpers import SearchDumper from invenio_records.systemfields import ConstantField, DictField, ModelField -from invenio_records_resources.records.api import Record +from invenio_records_resources.records.api import FileRecord, Record from invenio_records_resources.records.systemfields import IndexField -from werkzeug.utils import cached_property + +from invenio_requests.records.systemfields.files import RequestFilesField from ..customizations import RequestState as State from .dumpers import ( @@ -23,7 +27,7 @@ GrantTokensDumperExt, ParentChildDumperExt, ) -from .models import RequestEventModel, RequestMetadata +from .models import RequestEventModel, RequestFileMetadata, RequestMetadata from .systemfields import ( EntityReferenceField, EventTypeField, @@ -56,12 +60,8 @@ class RequestEvent(Record): model_cls = RequestEventModel - dumper = SearchDumper( - extensions=[ - ParentChildDumperExt(), - ] - ) - """Search dumper with parent-child relationship extension.""" + dumper = SearchDumper(extensions=[ParentChildDumperExt()]) + """Search dumper with parent-child relationship extension and files extension.""" # Systemfields metadata = None @@ -109,6 +109,35 @@ def pre_commit(self): super().pre_commit() +def get_files_quota(record=None): + """Get bucket quota configuration for request files.""" + # Returns quota_size and max_file_size from Flask config + # with defaults (100MB total quota, 10MB max file size) + return dict( + quota_size=current_app.config["REQUESTS_FILES_DEFAULT_QUOTA_SIZE"], + max_file_size=current_app.config["REQUESTS_FILES_DEFAULT_MAX_FILE_SIZE"], + ) + + +class RequestFile(FileRecord): + """Request file API.""" + + model_cls = RequestFileMetadata + + @classmethod + def list_by_file_ids(cls, record_id, file_ids): + """Get record files by record ID and file IDs.""" + with db.session.no_autoflush: + query = cls.model_cls.query.join(ObjectVersion).filter( + cls.model_cls.record_id == record_id, + ObjectVersion.file_id.in_(file_ids), + cls.model_cls.is_deleted != True, + ) + + for obj in query: + yield cls(obj.data, model=obj) + + class Request(Record): """A generic request record.""" @@ -183,3 +212,16 @@ class Request(Record): is_locked = DictField("is_locked") """Whether or not the request is locked.""" + + bucket_id = ModelField(dump=False) + bucket = ModelField(dump=False) + + # Files NOT dumped or stored in JSON - internal only + files = RequestFilesField( + store=False, # Don't serialize to request JSON + dump=False, # Don't include in dumps() + file_cls=RequestFile, + delete=False, # Manual management via service + create=False, # Lazy initialization + bucket_args=get_files_quota, # Quota config + ) diff --git a/invenio_requests/records/jsonschemas/requests/request-v1.0.0.json b/invenio_requests/records/jsonschemas/requests/request-v1.0.0.json index a4f9e471..a269812f 100644 --- a/invenio_requests/records/jsonschemas/requests/request-v1.0.0.json +++ b/invenio_requests/records/jsonschemas/requests/request-v1.0.0.json @@ -45,6 +45,17 @@ "items": { "$ref": "local://definitions-v1.0.0.json#/entity_reference" } + }, + "files": { + "type": "object", + "description": "Files associated with the request", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Set to false for metadata only requests." + } + } } } } diff --git a/invenio_requests/records/mappings/os-v1/requests/request-v1.0.0.json b/invenio_requests/records/mappings/os-v1/requests/request-v1.0.0.json index f2bb9eb4..ecaa3f24 100644 --- a/invenio_requests/records/mappings/os-v1/requests/request-v1.0.0.json +++ b/invenio_requests/records/mappings/os-v1/requests/request-v1.0.0.json @@ -149,6 +149,14 @@ }, "is_locked": { "type": "boolean" + }, + "files": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } } } } diff --git a/invenio_requests/records/mappings/os-v2/requests/request-v1.0.0.json b/invenio_requests/records/mappings/os-v2/requests/request-v1.0.0.json index a008638b..3643802a 100644 --- a/invenio_requests/records/mappings/os-v2/requests/request-v1.0.0.json +++ b/invenio_requests/records/mappings/os-v2/requests/request-v1.0.0.json @@ -151,6 +151,14 @@ }, "is_locked": { "type": "boolean" + }, + "files": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } } } } diff --git a/invenio_requests/records/mappings/v7/requests/request-v1.0.0.json b/invenio_requests/records/mappings/v7/requests/request-v1.0.0.json index 7b4a71ac..9fa9fba1 100644 --- a/invenio_requests/records/mappings/v7/requests/request-v1.0.0.json +++ b/invenio_requests/records/mappings/v7/requests/request-v1.0.0.json @@ -151,6 +151,14 @@ }, "is_locked": { "type": "boolean" + }, + "files": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } } } } diff --git a/invenio_requests/records/models.py b/invenio_requests/records/models.py index 229a6389..3a32efac 100644 --- a/invenio_requests/records/models.py +++ b/invenio_requests/records/models.py @@ -11,7 +11,9 @@ import uuid from invenio_db import db +from invenio_files_rest.models import Bucket from invenio_records.models import RecordMetadataBase +from invenio_records_resources.records import FileRecordModelMixin from sqlalchemy import ForeignKeyConstraint, UniqueConstraint, func from sqlalchemy.dialects import mysql from sqlalchemy.exc import IntegrityError @@ -37,6 +39,26 @@ class RequestMetadata(db.Model, RecordMetadataBase): nullable=True, ) + # Files attachment support + bucket_id = db.Column( + UUIDType, + db.ForeignKey(Bucket.id, ondelete="RESTRICT"), + nullable=True, # Nullable for lazy initialization (bucket only created on first file upload) + index=True, + ) + bucket = db.relationship(Bucket) + + +class RequestFileMetadata(db.Model, RecordMetadataBase, FileRecordModelMixin): + """Files associated with a request.""" + + __record_model_cls__ = RequestMetadata + + __tablename__ = "request_files" + + # JSON field structure: {"original_filename": "..."} + # Comments track file associations via payload.files array (by file_id) + class RequestEventModel(db.Model, RecordMetadataBase): """Request Events model.""" diff --git a/invenio_requests/records/systemfields/files.py b/invenio_requests/records/systemfields/files.py new file mode 100644 index 00000000..61266184 --- /dev/null +++ b/invenio_requests/records/systemfields/files.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2026 CERN. +# +# 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. + +"""Files field allow for explicit setting after initialization.""" + +from invenio_records_resources.records.systemfields.files.field import FilesField + + +class RequestFilesField(FilesField): + """Request Files system field.""" + + def __init__(self, *args, **kwargs): + """Initialize the RequestFilesField.""" + super().__init__(*args, **kwargs) + + # + # Data descriptor methods (i.e. attribute access) + # + def __set__(self, record, value): + """Set the request's files field.""" + assert record is not None + + self.set_dictkey(record, value) diff --git a/invenio_requests/resources/__init__.py b/invenio_requests/resources/__init__.py index 3d2b6ee0..e803d8d7 100644 --- a/invenio_requests/resources/__init__.py +++ b/invenio_requests/resources/__init__.py @@ -11,6 +11,7 @@ """Resources module.""" from .events import RequestCommentsResource, RequestCommentsResourceConfig +from .files import RequestFilesResource, RequestFilesResourceConfig from .requests import RequestsResource, RequestsResourceConfig __all__ = ( @@ -18,4 +19,6 @@ "RequestsResourceConfig", "RequestCommentsResource", "RequestCommentsResourceConfig", + "RequestFilesResource", + "RequestFilesResourceConfig", ) diff --git a/invenio_requests/resources/events/config.py b/invenio_requests/resources/events/config.py index 82c1df1b..6441c335 100644 --- a/invenio_requests/resources/events/config.py +++ b/invenio_requests/resources/events/config.py @@ -41,6 +41,7 @@ class RequestCommentsResourceConfig(RecordResourceConfig): "item": "//comments/", "reply": "//comments//reply", "replies": "//comments//replies", + "replies_focused": "//comments//replies_focused", "timeline": "//timeline", "timeline_focused": "//timeline_focused", } @@ -51,6 +52,7 @@ class RequestCommentsResourceConfig(RecordResourceConfig): # request. request_list_view_args = { "request_id": fields.UUID(), + "comment_id": fields.Str(), } request_item_view_args = { "request_id": fields.Str(), diff --git a/invenio_requests/resources/events/resource.py b/invenio_requests/resources/events/resource.py index 53ea6003..a78738ba 100644 --- a/invenio_requests/resources/events/resource.py +++ b/invenio_requests/resources/events/resource.py @@ -64,6 +64,7 @@ def create_url_rules(self): route("GET", routes["timeline"], self.search), route("GET", routes["timeline_focused"], self.focused_list), route("GET", routes["replies"], self.get_replies), + route("GET", routes["replies_focused"], self.focused_replies), ] @list_view_args_parser @@ -191,3 +192,19 @@ def get_replies(self): expand=resource_requestctx.args.get("expand", False), ) return hits.to_dict(), 200 + + @list_view_args_parser + @request_extra_args + @search_args_parser + @response_handler(many=True) + def focused_replies(self): + """List the reply page containing the event with ID focus_event_id, or the first page of results if this is not found.""" + hits = self.service.focused_reply_list( + identity=g.identity, + parent_id=resource_requestctx.view_args["comment_id"], + focus_reply_event_id=resource_requestctx.args.get("focus_event_id"), + page_size=resource_requestctx.args.get("size"), + expand=resource_requestctx.args.get("expand", False), + params=resource_requestctx.args, + ) + return hits.to_dict(), 200 diff --git a/invenio_requests/resources/files/__init__.py b/invenio_requests/resources/files/__init__.py new file mode 100644 index 00000000..4e744bb2 --- /dev/null +++ b/invenio_requests/resources/files/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request Files Resource module.""" + +from .config import RequestFilesResourceConfig +from .resource import RequestFilesResource + +__all__ = ( + "RequestFilesResource", + "RequestFilesResourceConfig", +) diff --git a/invenio_requests/resources/files/config.py b/invenio_requests/resources/files/config.py new file mode 100644 index 00000000..6726f2d8 --- /dev/null +++ b/invenio_requests/resources/files/config.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""RequestFiles Resource Configuration.""" + +from flask_resources import HTTPJSONException, create_error_handler +from invenio_records_resources.resources import RecordResourceConfig +from marshmallow import fields + +from invenio_requests.services.files.errors import ( + RequestFileNotFoundError, + RequestFileSizeLimitError, +) + + +class RequestFilesResourceConfig(RecordResourceConfig): + """Request Files resource configuration.""" + + blueprint_name = "request_files" + url_prefix = "/requests" + routes = { + "create": "//files/upload/", + "item": "//files/", + "item_content": "//files//content", + } + + request_view_args = { + "id": fields.UUID(), + "key": fields.Str(), + } + + response_handlers = { + "application/vnd.inveniordm.v1+json": RecordResourceConfig.response_handlers[ + "application/json" + ], + **RecordResourceConfig.response_handlers, + } + + error_handlers = { + **RecordResourceConfig.error_handlers, + RequestFileSizeLimitError: create_error_handler( + lambda e: HTTPJSONException( + code=400, + description=str(e), + ) + ), + RequestFileNotFoundError: create_error_handler( + lambda e: HTTPJSONException( + code=404, + description=str(e), + ) + ), + } diff --git a/invenio_requests/resources/files/resource.py b/invenio_requests/resources/files/resource.py new file mode 100644 index 00000000..165ea2a8 --- /dev/null +++ b/invenio_requests/resources/files/resource.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""RequestFiles Resource.""" + +from flask import g +from flask_resources import resource_requestctx, response_handler, route +from invenio_records_resources.resources import RecordResource +from invenio_records_resources.resources.files.resource import request_stream +from invenio_records_resources.resources.records.resource import ( + request_extra_args, + request_headers, + request_view_args, +) + + +# +# Resource +# +class RequestFilesResource(RecordResource): + """Resource for Request files.""" + + def create_url_rules(self): + """Create the URL rules for the record resource.""" + routes = self.config.routes + return [ + route("PUT", routes["create"], self.create), + route("DELETE", routes["item"], self.delete), + route("GET", routes["item_content"], self.read), + ] + + @request_extra_args + @request_headers + @request_view_args + @request_stream + @response_handler() + def create(self): + """Create a file.""" + item = self.service.create_file( + identity=g.identity, + id_=resource_requestctx.view_args["id"], + key=resource_requestctx.view_args["key"], + stream=resource_requestctx.data["request_stream"], + content_length=resource_requestctx.data["request_content_length"], + ) + return item.to_dict(), 200 + + @request_view_args + def delete(self): + """Delete a file.""" + self.service.delete_file( + identity=g.identity, + id_=resource_requestctx.view_args["id"], + file_key=resource_requestctx.view_args["key"], + ) + return "", 204 + + @request_view_args + def read(self): + """Read a file.""" + file = self.service.read_file( + identity=g.identity, + id_=resource_requestctx.view_args["id"], + file_key=resource_requestctx.view_args["key"], + ) + return file.send_file(as_attachment=True, restricted=True), 200 diff --git a/invenio_requests/services/__init__.py b/invenio_requests/services/__init__.py index 81c704b8..f55aaf87 100644 --- a/invenio_requests/services/__init__.py +++ b/invenio_requests/services/__init__.py @@ -11,12 +11,15 @@ """Services module.""" from .events import RequestEventsService, RequestEventsServiceConfig +from .files import RequestFilesService, RequestFilesServiceConfig from .requests import RequestsService, RequestsServiceConfig from .user_moderation import UserModerationRequestService __all__ = ( "RequestEventsService", "RequestEventsServiceConfig", + "RequestFilesService", + "RequestFilesServiceConfig", "RequestsService", "RequestsServiceConfig", "UserModerationRequestService", diff --git a/invenio_requests/services/events/config.py b/invenio_requests/services/events/config.py index 51bbb553..d6b01909 100644 --- a/invenio_requests/services/events/config.py +++ b/invenio_requests/services/events/config.py @@ -9,6 +9,8 @@ """Request Events Service Config.""" +from uuid import UUID + from invenio_indexer.api import RecordIndexer from invenio_records_resources.services import ( RecordServiceConfig, @@ -21,16 +23,95 @@ RecordList, ) -from invenio_requests.services.links import ( +from ...proxies import ( + current_request_files_service, +) +from ...records.api import Request, RequestEvent, RequestFile +from ...services.links import ( RequestSingleCommentEndpointLink, RequestTypeDependentEndpointLink, ) - -from ...records.api import Request, RequestEvent from ..permissions import PermissionPolicy from ..schemas import RequestEventSchema +def _expand_files_for_projections(projections, request, identity): + """Expand file details for multiple projections in a single batch query. + + :param projections: List of projection dictionaries + :param request: The request object for context + :param identity: The identity for link generation + """ + if not projections or not request: + return + + request_id = request.id + + # Collect all file IDs from all projections + all_file_ids = set() + for projection in projections: + payload_files = projection.get("payload", {}).get("files", []) + for file_obj in payload_files: + if "file_id" in file_obj: + all_file_ids.add(UUID(file_obj["file_id"])) + + # Early return if no files to expand + if not all_file_ids: + return + + # Single batch query to fetch all files + request_files = RequestFile.list_by_file_ids(request_id, list(all_file_ids)) + request_files_by_file_id = { + request_file.file.file_id: request_file for request_file in request_files + } + + # Create link template using the files service factory + file_links_tpl = current_request_files_service.links_tpl_factory( + current_request_files_service.config.links_item, + request_type=str(request.type), + ) + + # Expand files in each projection + for projection in projections: + payload_files = projection.get("payload", {}).get("files", []) + + # Nothing to expand if no files + if not payload_files: + continue + + # Build expanded files list + expanded_files = [] + for payload_file in payload_files: + if "file_id" not in payload_file: + continue + + payload_file_uuid = UUID(payload_file["file_id"]) + file_details = request_files_by_file_id.get(payload_file_uuid) + + # Only expand if the file is found in the database + if file_details: + expanded_files.append( + { + "file_id": str(file_details.file.file_id), + "key": file_details.file.key, + "original_filename": file_details.model.data[ + "original_filename" + ], + "size": file_details.file.size, + "mimetype": file_details.file.mimetype, + "created": file_details.file.created, + # Use link template to expand links + "links": file_links_tpl.expand(identity, file_details), + } + ) + + # Add expanded files to the projection's expanded field + if expanded_files: + if "expanded" not in projection: + projection["expanded"] = {} + projection["expanded"]["files"] = expanded_files + + class RequestEventItem(RecordItem): """RequestEvent result item.""" @@ -71,6 +152,9 @@ def data(self): fields = self._fields_resolver.expand(self._identity, self._data) self._data["expanded"] = fields + # Expand file details if present + _expand_files_for_projections([self._data], self._request, self._identity) + return self._data @@ -92,6 +176,9 @@ def to_dict(self): if self._expand and self._fields_resolver: self._expand_children_fields(res["hits"]["hits"]) + # Batch expand file details for all hits + self._batch_expand_file_details(res["hits"]["hits"]) + return res def _expand_children_fields(self, hits): @@ -114,6 +201,21 @@ def _expand_children_fields(self, hits): fields = self._fields_resolver.expand(self._identity, child) child["expanded"] = fields + def _batch_expand_file_details(self, hits): + """Batch expand file details for all hits in a single database query. + + :param hits: List of hit dictionaries that may contain payload.files + """ + # Collect all projections (parents and children) + all_projections = [] + for hit in hits: + all_projections.append(hit) + # Also include children + all_projections.extend(hit.get("children", [])) + + # Use common expansion function + _expand_files_for_projections(all_projections, self._request, self._identity) + @property def hits(self): """Iterator over the hits.""" @@ -209,7 +311,10 @@ def request_event_anchor(_, vars): if request_event is None: return None - return f"commentevent-{request_event.id}" + if request_event.parent_id is not None: + return f"commentevent-{request_event.parent_id}_{request_event.id}" + else: + return f"commentevent-{request_event.id}" class RequestEventsServiceConfig(RecordServiceConfig, ConfiguratorMixin): @@ -258,6 +363,10 @@ class RequestEventsServiceConfig(RecordServiceConfig, ConfiguratorMixin): # only case where there *can* be replies when=lambda obj, vars: obj.parent_id is None, ), + "replies_focused": RequestSingleCommentEndpointLink( + "request_events.focused_replies", + when=lambda obj, vars: obj.parent_id is None, + ), } links_search = pagination_endpoint_links( diff --git a/invenio_requests/services/events/service.py b/invenio_requests/services/events/service.py index 788966f4..6fd7736e 100644 --- a/invenio_requests/services/events/service.py +++ b/invenio_requests/services/events/service.py @@ -49,7 +49,13 @@ def _wrap_schema(self, schema): @property def expandable_fields(self): - """Get expandable fields.""" + """Get expandable fields for request events. + + Includes: + - created_by: User or email who created the event + + Note: payload.files: File attachments in comment events are resolved in the result class. + """ return [EntityResolverExpandableField("created_by")] def links_tpl_factory(self, links, **context): @@ -230,6 +236,7 @@ def update(self, identity, id_, data, revision_id=None, uow=None, expand=False): identity=identity, record=event, request=request, event_type=event.type ), ) + event["payload"]["content"] = data["payload"]["content"] event["payload"]["format"] = data["payload"]["format"] @@ -243,6 +250,11 @@ def update(self, identity, id_, data, revision_id=None, uow=None, expand=False): uow=uow, ) + # Updating the list of files after running components, + # so that RequestCommentFileCleanupComponent can compare the list of files. + if "files" in data["payload"]: + event["payload"]["files"] = data["payload"]["files"] + # Persist record (DB and index) uow.register(RecordCommitOp(event, indexer=self.indexer)) @@ -294,7 +306,10 @@ def delete(self, identity, id_, revision_id=None, uow=None): data, context=dict(identity=identity, record=event, event_type=event.type), ) - event["payload"] = data["payload"] + + for data_key, data_value in data["payload"].items(): + if data_key != "files": + event["payload"][data_key] = data_value # Run components self.run_components( @@ -306,6 +321,11 @@ def delete(self, identity, id_, revision_id=None, uow=None): uow=uow, ) + # Updating the list of files after running components, + # so that RequestCommentFileCleanupComponent can get the list of files. + if "files" in data["payload"]: + event["payload"]["files"] = data["payload"]["files"] + # Commit the updated comment uow.register(RecordCommitOp(event, indexer=self.indexer)) @@ -321,7 +341,7 @@ def search( params=None, search_preference=None, preview_size=None, - **kwargs + **kwargs, ): """Search for events (timeline) for a given request. @@ -397,6 +417,7 @@ def focused_list( """Return a page of results focused on a given event, or the first page if the event is not found. Only searches parent comments (excludes child comments/replies). + If the focused event is a reply (child comment), the page containing its parent will be returned. """ # Permissions - guarded by the request's can_read. request = self._get_request(request_id) @@ -410,15 +431,15 @@ def focused_list( # might not be valid for this particular event. if str(focus_event.request_id) != str(request_id): raise PermissionDeniedError() + + if focus_event.parent_id is not None: + focus_event = self._get_event(focus_event.parent_id) except sqlalchemy.exc.NoResultFound: # Silently ignore pass params = {"sort": "oldest", "size": page_size} - # TODO: this needs to be adpated to focus on links to child comments - # See https://github.com/inveniosoftware/invenio-requests/issues/542 - # Build filter to only include parent comments (exclude child comments) parent_filter = dsl.Q( "bool", @@ -474,7 +495,7 @@ def scan( search_preference=None, expand=False, extra_filter=None, - **kwargs + **kwargs, ): """Scan for events matching the querystring.""" request = self._get_request(request_id) @@ -566,6 +587,97 @@ def get_comment_replies( ), expandable_fields=self.expandable_fields, expand=expand, + request=request, + ) + + def focused_reply_list( + self, + identity, + parent_id, + focus_reply_event_id, + page_size, + expand=False, + search_preference=None, + params=None, + ): + """Return a page of results focused on a given reply event, or the first page if the event is not found. + + Only searches reply comments (excludes parents comments/replies). + """ + parent_event = self._get_event(parent_id) + request = self._get_request(parent_event.request_id) + # Permissions - guarded by the request's can_read. + self.require_permission(identity, "read", request=request) + + # If a specific event ID is requested, we need to work out the corresponding page number. + focus_event = None + try: + focus_event = self._get_event(focus_reply_event_id) + # Make sure the event belongs to the request, otherwise the `require_permission` call above + # might not be valid for this particular event. + if str(focus_event.request_id) != str(parent_event.request_id): + raise PermissionDeniedError() + + if str(focus_event.parent_id) != str(parent_id): + raise Exception("Focus event must be a reply to the parent.") + + if focus_event.parent_id is None: + raise Exception("Cannot focus on non-reply event.") + except sqlalchemy.exc.NoResultFound: + # Silently ignore + pass + + params = params or {} + params.setdefault("sort", "newest") + params.setdefault("size", page_size) + + replies_filter = dsl.Q( + "bool", + must=[ + dsl.Q("term", request_id=str(request.id)), + dsl.Q("term", parent_id=parent_id), + ], + ) + search = self._search( + "search", + identity, + params, + search_preference, + permission_action="unused", + extra_filter=replies_filter, + versioning=False, + ) + + page = 1 + if focus_event is not None: + num_newer_than_event = search.filter( + "range", created={"gt": focus_event.created} + ).count() + page = num_newer_than_event // page_size + 1 + + # Re run the pagination param interpreter to update the search with the new page number + params.update(page=page) + search = PaginationParam(self.config.search).apply(identity, search, params) + + # We deactivated versioning before (it doesn't apply for count queries) so we need to re-enable it. + search_result = search.params(version=True).execute() + return self.result_list( + self, + identity, + search_result, + params, + links_tpl=self.links_tpl_factory( + self.config.links_replies, + request_id=str(request.id), + comment_id=parent_id, + args=params, + ), + links_item_tpl=self.links_tpl_factory( + self.config.links_item, request=request, request_type=request.type + ), + expandable_fields=self.expandable_fields, + expand=expand, + request=request, ) # Utilities diff --git a/invenio_requests/services/files/__init__.py b/invenio_requests/services/files/__init__.py new file mode 100644 index 00000000..2f173eb0 --- /dev/null +++ b/invenio_requests/services/files/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request file services module.""" + +from .config import RequestFilesServiceConfig +from .service import RequestFilesService + +__all__ = ( + "RequestFilesService", + "RequestFilesServiceConfig", +) diff --git a/invenio_requests/services/files/config.py b/invenio_requests/services/files/config.py new file mode 100644 index 00000000..3801f1c1 --- /dev/null +++ b/invenio_requests/services/files/config.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Requests service configuration.""" + +from invenio_records_resources.services import ( + FileServiceConfig, + Link, +) +from invenio_records_resources.services.base.config import ConfiguratorMixin, FromConfig +from invenio_records_resources.services.files.links import FileEndpointLink +from invenio_records_resources.services.records.results import RecordItem + +from ...records.api import Request, RequestFile +from ..permissions import PermissionPolicy +from ..schemas import RequestFileSchema + + +class RequestFileItem(RecordItem): + """RequestFile result item.""" + + @property + def id(self): + """Id property.""" + return self._record.id + + +class RequestFileLink(Link): + """Link variables setter for RequestFile links.""" + + @staticmethod + def vars(obj, vars): + """Variables for the URI template.""" + vars.update({"request_id": obj.model.record_id, "key": obj.key}) + # Remark: we do not call `request_type._update_link_config` + # because we do not want file links to be modified depending on the context (e.g. `/me`) + + +class RequestFilesServiceConfig(FileServiceConfig, ConfiguratorMixin): + """Requests Files service configuration.""" + + service_id = "request_files" + + # common configuration + permission_action_prefix = "" + permission_policy_cls = FromConfig( + "REQUESTS_PERMISSION_POLICY", default=PermissionPolicy + ) + result_item_cls = RequestFileItem + + # request files-specific configuration + record_cls = RequestFile # needed for model queries + schema = RequestFileSchema # stored in the API classes, for customization + request_cls = Request + indexer_queue_name = "files" + + # links configuration / ResultItem configurations + links_item = { + "self": RequestFileLink("{+api}/requests/{request_id}/files/{key}"), + "content": RequestFileLink("{+api}/requests/{request_id}/files/{key}/content"), + "download_html": RequestFileLink("{+ui}/requests/{request_id}/files/{key}"), + } + + file_links_item = { + "self": FileEndpointLink("request_files.read", params=["pid_value", "key"]), + } + + components = FromConfig( + "REQUESTS_FILES_SERVICE_COMPONENTS", + default=[], + ) diff --git a/invenio_requests/services/files/errors.py b/invenio_requests/services/files/errors.py new file mode 100644 index 00000000..103b53dc --- /dev/null +++ b/invenio_requests/services/files/errors.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Requests files errors.""" + +from invenio_i18n import gettext as _ + + +class RequestFileSizeLimitError(Exception): + """The provided file size exceeds limit.""" + + def __init__(self): + """Initialise error.""" + super().__init__(_("File size exceeds limit")) + + +class RequestFileNotFoundError(Exception): + """The provided file is not found.""" + + def __init__(self): + """Initialise error.""" + super().__init__(_("File not found")) + + +class RequestFileArgumentMissingError(Exception): + """The provided file argument is missing.""" + + def __init__(self): + """Initialise error.""" + super().__init__(_("Missing required argument file_key or file_id")) diff --git a/invenio_requests/services/files/service.py b/invenio_requests/services/files/service.py new file mode 100644 index 00000000..d0eabdf9 --- /dev/null +++ b/invenio_requests/services/files/service.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request files service.""" + +from os.path import splitext +from uuid import UUID + +from base32_lib import base32 +from flask import current_app +from invenio_records_resources.services import FileService, ServiceSchemaWrapper +from invenio_records_resources.services.base.links import LinksTemplate +from invenio_records_resources.services.uow import RecordCommitOp, unit_of_work + +from invenio_requests.services.files.errors import ( + RequestFileArgumentMissingError, + RequestFileNotFoundError, + RequestFileSizeLimitError, +) + + +class RequestFilesService(FileService): + """Service for managing request file attachments.""" + + def _wrap_schema(self, schema): + """Wrap schema.""" + return ServiceSchemaWrapper(self, schema) + + # Utilities + @property + def request_cls(self): + """Get associated request class.""" + return self.config.request_cls + + def links_tpl_factory(self, links, **context): + """Include context information in the link template. + + This way, the link URLs can be contextualised depending e.g. on the type of the event's + parent request. + """ + return LinksTemplate(links, context=context) + + @unit_of_work() + def create_file(self, identity, id_, key, stream, content_length, uow=None): + """Upload a file in a single operation (simple endpoint). + + Convenience method that combines init/upload/commit into one operation. + """ + # Resolve and check permissions + request = self.request_cls.get_record(id_) + self.require_permission(identity, "manage_files", request=request) + + # File size validation + max_file_size = current_app.config["REQUESTS_FILES_DEFAULT_MAX_FILE_SIZE"] + if content_length > max_file_size: + raise RequestFileSizeLimitError() + + # Unique key generation + unique_id = base32.generate(length=10, split_every=5, checksum=True) + key_root, key_ext = splitext(key) + unique_key = f"{key_root}-{unique_id}{key_ext}" + + # Lazy files enable initialization (for requests created before request files support) + if request.files is None: + # RequestFilesField allows to set the files field after initialization. + request.files = {"enabled": True} + + # Lazy bucket initialization + if request.files.bucket is None: + request.files.create_bucket() + + request.files[unique_key] = stream + # Store the original filename in RequestFileMetadata.json (field from RecordMetadataBase) + request.files[unique_key].model.data = {"original_filename": key} + + uow.register(RecordCommitOp(request)) + + return self.result_item( + self, + identity, + request.files[unique_key], + schema=self._wrap_schema(self.config.schema), + links_tpl=self.links_tpl_factory( + self.config.links_item, request_type=str(request.type) + ), + ) + + @unit_of_work() + def delete_file(self, identity, id_, file_key=None, file_id=None, uow=None): + """Delete a specific file.""" + # Called explicitly via API or by frontend when file removed from comment + + if file_key is None and file_id is None: + raise RequestFileArgumentMissingError() + + # Resolve and check permissions + request = self.request_cls.get_record(id_) + self.require_permission(identity, "manage_files", request=request) + + if file_key is not None: + deleted_file = request.files.pop(file_key, None) + else: + deleted_file = None + file_id_uuid = UUID(file_id) + for request_file_key in request.files: + request_file = request.files[request_file_key] + if request_file.file.id == file_id_uuid: + deleted_file = request.files.pop(request_file_key, None) + break + + if deleted_file is None: + raise RequestFileNotFoundError() + + deleted_file.delete(force=True) + + uow.register(RecordCommitOp(request)) + + return self.result_item( + self, + identity, + deleted_file, + schema=self._wrap_schema(self.config.schema), + links_tpl=self.links_tpl_factory( + self.config.links_item, request_type=str(request.type) + ), + ) + + def read_file(self, identity, id_, file_key): + """Retrieve file content for download/display.""" + # Resolve and check permissions + request = self.request_cls.get_record(id_) + self.require_permission(identity, "read_files", request=request) + + # Return file stream + request_file = request.files.get(file_key) + if request_file is None: + raise RequestFileNotFoundError() + + return self.file_result_item( + self, + identity, + request_file, + request, + links_tpl=self.file_links_item_tpl(id_), + ) diff --git a/invenio_requests/services/permissions.py b/invenio_requests/services/permissions.py index 2b272ff1..7cd47521 100644 --- a/invenio_requests/services/permissions.py +++ b/invenio_requests/services/permissions.py @@ -130,3 +130,9 @@ class PermissionPolicy(RecordPermissionPolicy): # be provided to create_search(), but the event search is already protected # by request's can_read, thus we use a dummy permission for the search. can_unused = [AnyUser()] + + # Manage (Upload/Delete) files: Same as creating comments on the request. + can_manage_files = can_create_comment + + # Read (View/Download) files: Same permission as viewing the request and its timeline. + can_read_files = can_read diff --git a/invenio_requests/services/requests/components.py b/invenio_requests/services/requests/components.py index 005d829d..0487e401 100644 --- a/invenio_requests/services/requests/components.py +++ b/invenio_requests/services/requests/components.py @@ -9,8 +9,11 @@ """Component for creating request numbers.""" +from uuid import UUID + from flask import current_app from invenio_i18n import _ +from invenio_records_resources.services.errors import ValidationErrorGroup from invenio_records_resources.services.records.components import ( DataComponent, ServiceComponent, @@ -21,7 +24,11 @@ LogEventType, ReviewersUpdatedType, ) -from invenio_requests.proxies import current_events_service +from invenio_requests.proxies import ( + current_events_service, + current_request_files_service, +) +from invenio_requests.records.api import RequestFile class RequestNumberComponent(ServiceComponent): @@ -199,3 +206,90 @@ def unlock_request(self, identity, record=None, uow=None, **kwargs): event = LogEventType(payload=dict(event="unlocked")) _data = dict(payload=event.payload) current_events_service.create(identity, record.id, _data, event, uow=uow) + + +class RequestCommentFileValidationComponent(ServiceComponent): + """Component validating files referenced in a request comment.""" + + def _check_file_references( + self, identity, data=None, event=None, uow=None, **kwargs + ): + # The new list of files received from the current service call. + comment_file_ids_next = [ + UUID(file_next["file_id"]) + for file_next in data.get("payload", {}).get("files", []) + ] + + # Retrieve the existing (persisted) list of files with the given file IDs which are associated to the request. + request_files_existing = RequestFile.list_by_file_ids( + event["request_id"], comment_file_ids_next + ) + request_file_ids_existing = { + request_file_existing.file.file_id + for request_file_existing in request_files_existing + } + + # Check if all the file IDs exist in the database. + error_messages = [] + for idx, comment_file_id_next in enumerate(comment_file_ids_next): + if comment_file_id_next not in request_file_ids_existing: + error_messages.append( + { + "messages": [_(f"File {str(comment_file_id_next)} not found.")], + "field": f"payload.files[{idx}]", + } + ) + if error_messages: + raise ValidationErrorGroup(error_messages) + + def create(self, identity, data=None, event=None, errors=None, uow=None, **kwargs): + """Ensures all referenced files exist.""" + self._check_file_references(identity, data=data, event=event, uow=uow, **kwargs) + + def update_comment( + self, identity, data=None, event=None, request=None, uow=None, **kwargs + ): + """Ensures all referenced files exist.""" + self._check_file_references(identity, data=data, event=event, uow=uow, **kwargs) + + +class RequestCommentFileCleanupComponent(ServiceComponent): + """Component deleting files which references were removed form a request comment.""" + + def update_comment( + self, identity, data=None, event=None, request=None, uow=None, **kwargs + ): + """Delete files not referenced anymore in a comment.""" + # The existing (persisted) list of files associated to the comment. + file_ids_previous = [ + file_previous["file_id"] + for file_previous in event["payload"].get("files", []) + ] + + # The new list of files received from the current service call. + file_ids_next = [ + file_next["file_id"] for file_next in data["payload"].get("files", []) + ] + + # Delete files from the persisted list which are not in the new list anymore. + for file_id_previous in file_ids_previous: + if file_id_previous not in file_ids_next: + current_request_files_service.delete_file( + identity, event["request_id"], file_id=file_id_previous, uow=uow + ) + + def delete_comment( + self, identity, data=None, event=None, request=None, uow=None, **kwargs + ): + """Delete files not associated to a deleted comment.""" + # The existing (persisted) list of files associated to the comment. + file_ids_previous = [ + file_previous["file_id"] + for file_previous in event["payload"].get("files", []) + ] + + # Delete all the files. + for file_id_previous in file_ids_previous: + current_request_files_service.delete_file( + identity, event["request_id"], file_id=file_id_previous, uow=uow + ) diff --git a/invenio_requests/services/schemas.py b/invenio_requests/services/schemas.py index 86d881c2..53521f07 100644 --- a/invenio_requests/services/schemas.py +++ b/invenio_requests/services/schemas.py @@ -15,10 +15,12 @@ from invenio_records_resources.services.records.schema import BaseRecordSchema from marshmallow import ( RAISE, + Schema, fields, ) from marshmallow_utils import fields as utils_fields from marshmallow_utils.context import context_schema +from marshmallow_utils.fields import Links from invenio_requests.proxies import current_requests @@ -45,38 +47,54 @@ class RequestEventSchema(BaseRecordSchema): def get_permissions(self, obj): """Return permissions to act on comments or empty dict.""" - is_comment = obj.type == CommentEventType + service = current_requests.request_events_service + current_identity = context_schema.get()["identity"] current_request = context_schema.get().get("request", None) - if is_comment: - service = current_requests.request_events_service - permissions = { - "can_update_comment": service.check_permission( - current_identity, - "update_comment", - event=obj, - request=current_request, - ), - "can_delete_comment": service.check_permission( - current_identity, - "delete_comment", - event=obj, - request=current_request, - ), - } - - if current_request is not None: - permissions["can_reply_comment"] = service.check_permission( - current_identity, - "reply_comment", - event=obj, - request=current_request, - ) - - return permissions - else: + permissions = {} + + if current_request is None: return {} + if obj.type == CommentEventType: + permissions["can_update_comment"] = service.check_permission( + current_identity, + "update_comment", + event=obj, + request=current_request, + ) + permissions["can_delete_comment"] = service.check_permission( + current_identity, + "delete_comment", + event=obj, + request=current_request, + ) + else: + # Other event types (e.g. log events) might be deleted comments, for which these permissions are inherently False. + permissions["can_update_comment"] = False + permissions["can_delete_comment"] = False + + permissions["can_reply_comment"] = service.check_permission( + current_identity, + "reply_comment", + event=obj, + request=current_request, + ) + + return permissions + + +class RequestFileSchema(Schema): + """Schema for file requests.""" + + id = fields.UUID(attribute="file.file_id", dump_only=True) + key = fields.String(dump_only=True) + checksum = fields.String(attribute="file.checksum", dump_only=True) + mimetype = fields.String(attribute="file.mimetype", dump_only=True) + size = fields.Integer(attribute="file.size", dump_only=True) + metadata = fields.Dict(attribute="model.data", dump_only=True) + links = Links() + class RequestSchema(BaseRecordSchema): """Schema for requests. diff --git a/invenio_requests/templates/semantic-ui/invenio_notifications/comment-request-event.reply.jinja b/invenio_requests/templates/semantic-ui/invenio_notifications/comment-request-event.reply.jinja index df75da39..38d3fa5f 100644 --- a/invenio_requests/templates/semantic-ui/invenio_notifications/comment-request-event.reply.jinja +++ b/invenio_requests/templates/semantic-ui/invenio_notifications/comment-request-event.reply.jinja @@ -7,15 +7,15 @@ {% set request_event_content = invenio_request_event.payload.content | safe %} {% set request_event_content = invenio_request_event.payload.content | safe %} {% set request_title = invenio_request.title | safe %} -{% set parent_comment_id = invenio_request_event.parent_id %} +{% set reply_id = invenio_request_event.id %} {# TODO: use request.links.self_html when issue issue is resolved: https://github.com/inveniosoftware/invenio-rdm-records/issues/1327 #} {% set request_link = "{ui}/me/requests/{id}".format( ui=config.SITE_UI_URL, id=request_id ) %} -{% set parent_comment_link = "{ui}/me/requests/{id}#comment-{parent_id}".format( - ui=config.SITE_UI_URL, id=request_id, parent_id=parent_comment_id +{% set reply_link = "{ui}/me/requests/{id}#replyevent-{reply_id}".format( + ui=config.SITE_UI_URL, id=request_id, reply_id=reply_id ) %} {% set account_settings_link = "{ui}/account/settings/notifications".format( @@ -30,7 +30,7 @@ {%- block html_body -%} - + @@ -52,14 +52,14 @@ {{ request_event_content }} -{{ _("View comment: {parent_comment_link}").format(parent_comment_link=parent_comment_link) }} +{{ _("View reply: {reply_link}").format(reply_link=reply_link) }} {{ _("Check out the request: {request_link}").format(request_link=request_link) }} {%- endblock plain_body %} {# Markdown for Slack/Mattermost/chat #} {%- block md_body -%} -{{ _("*@{user_name}* replied on [comment]({parent_link})").format(user_name=event_creator_name, parent_link=parent_comment_link) }}. +{{ _("*@{user_name}* replied on [comment]({reply_link})").format(user_name=event_creator_name, reply_link=reply_link) }}. {{ request_event_content }} diff --git a/invenio_requests/views/__init__.py b/invenio_requests/views/__init__.py index 59bede3a..d50e981e 100644 --- a/invenio_requests/views/__init__.py +++ b/invenio_requests/views/__init__.py @@ -11,7 +11,7 @@ from flask import Blueprint -from .api import create_request_events_bp, create_requests_bp +from .api import create_request_events_bp, create_request_files_bp, create_requests_bp from .ui import create_ui_blueprint blueprint = Blueprint("invenio-requests-ext", __name__) @@ -22,4 +22,5 @@ "create_ui_blueprint", "create_requests_bp", "create_request_events_bp", + "create_request_files_bp", ) diff --git a/invenio_requests/views/api.py b/invenio_requests/views/api.py index feb5b5d4..ebcc4ba5 100644 --- a/invenio_requests/views/api.py +++ b/invenio_requests/views/api.py @@ -20,3 +20,9 @@ def create_request_events_bp(app): ext = app.extensions["invenio-requests"] # `request_events_resource` is really the RequestCommentsResource return ext.request_events_resource.as_blueprint() + + +def create_request_files_bp(app): + """Create request files blueprint.""" + ext = app.extensions["invenio-requests"] + return ext.request_files_resource.as_blueprint() diff --git a/invenio_requests/views/files/__init__.py b/invenio_requests/views/files/__init__.py new file mode 100644 index 00000000..a3542469 --- /dev/null +++ b/invenio_requests/views/files/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request Files views module.""" + +from .ui import create_ui_blueprint + +__all__ = ("create_ui_blueprint",) diff --git a/invenio_requests/views/files/requests.py b/invenio_requests/views/files/requests.py new file mode 100644 index 00000000..c7f447c8 --- /dev/null +++ b/invenio_requests/views/files/requests.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request Files views module.""" + +from flask import g + +from invenio_requests.proxies import current_request_files_service + + +def read_file(pid_value, file_key): + """Read a file.""" + file = current_request_files_service.read_file( + identity=g.identity, + id_=pid_value, + file_key=file_key, + ) + return file.send_file(as_attachment=True, restricted=True), 200 diff --git a/invenio_requests/views/files/ui.py b/invenio_requests/views/files/ui.py new file mode 100644 index 00000000..58ca3b59 --- /dev/null +++ b/invenio_requests/views/files/ui.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request Files ui views module.""" + +from flask import Blueprint, current_app, render_template +from flask_login import current_user +from invenio_pidstore.errors import PIDDeletedError, PIDDoesNotExistError +from invenio_records_resources.services.errors import PermissionDeniedError + +from .requests import read_file + + +def create_ui_blueprint(app): + """Register blueprint routes on app.""" + routes = app.config.get("REQUESTS_ROUTES") + + blueprint = Blueprint( + "invenio_requests_files", + __name__, + template_folder="../templates", + static_folder="../static", + ) + + # Here we add a non-API endpoint for HTML embedding and downloads, + # which unlike the API endpoint is not for programmatic file access. + blueprint.add_url_rule( + routes["download_file_html"], + view_func=read_file, + ) + + return blueprint diff --git a/setup.cfg b/setup.cfg index 3c7b0dc5..23eb8dd8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,11 +52,13 @@ invenio_base.api_apps = invenio_requests = invenio_requests:InvenioRequests invenio_base.blueprints = invenio_requests = invenio_requests.views:create_ui_blueprint + invenio_requests_files = invenio_requests.views.files:create_ui_blueprint invenio_requests_ext = invenio_requests.views:blueprint invenio_base.api_blueprints = invenio_requests = invenio_requests.views:create_requests_bp invenio_requests_ui = invenio_requests.views:create_ui_blueprint invenio_request_events = invenio_requests.views:create_request_events_bp + invenio_request_files = invenio_requests.views:create_request_files_bp invenio_requests_ext = invenio_requests.views:blueprint invenio_base.finalize_app = invenio_requests = invenio_requests.ext:finalize_app diff --git a/tests/customizations/test_event_types.py b/tests/customizations/test_event_types.py new file mode 100644 index 00000000..a0c6bd1f --- /dev/null +++ b/tests/customizations/test_event_types.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Tests for the provided event types customization mechanisms.""" + +# TODO: This is fully copied from invenio-pages. + +from flask import Flask, current_app + +from invenio_requests import InvenioRequests +from invenio_requests.config import ( + REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_ATTRS, + REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_TAGS, +) +from invenio_requests.customizations.event_types import RequestsCommentsSanitizedHTML + + +def test_extra_allowed_html_tags(): + """Test instance folder loading.""" + app = Flask("testapp") + InvenioRequests(app) + + assert ( + app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_ATTRS"] + == REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_ATTRS + ) + assert ( + app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_TAGS"] + == REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_TAGS + ) + + app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_ATTRS"] = ["a"] + app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_TAGS"] = ["a"] + InvenioRequests(app) + assert app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_ATTRS"] == ["a"] + assert app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_TAGS"] == ["a"] + + +def test_requests_comments_sanitized_html_initialization(): + """ + Test the initialization of the RequestsCommentsSanitizedHTML class. + + This test verifies that the default values for 'tags' and 'attrs' + attributes of a RequestsCommentsSanitizedHTML instance are set to None. + It asserts that both these attributes are None upon initialization, + ensuring that the class starts with no predefined allowed tags or attributes. + """ + html_sanitizer = RequestsCommentsSanitizedHTML() + assert html_sanitizer.tags is None + assert html_sanitizer.attrs is None + + +def test_requests_comments_sanitized_html(app): + """ + Tests RequestsCommentsSanitizedHTML with custom tags and attributes in an app context. + Verifies if custom settings are properly applied and reflected in the output. + """ + with app.app_context(): + # Set up the extra configuration + current_app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_TAGS"] = ["customtag"] + current_app.config["REQUESTS_COMMENTS_ALLOWED_EXTRA_HTML_ATTRS"] = { + "customtag": ["data-custom"] + } + + sanitizer = RequestsCommentsSanitizedHTML() + sample_html = 'Test' + result = sanitizer._deserialize(sample_html, None, None) + + assert 'Test' in result diff --git a/tests/resources/events/test_request_events_resources.py b/tests/resources/events/test_request_events_resources.py index 96e73c55..58b4ea19 100644 --- a/tests/resources/events/test_request_events_resources.py +++ b/tests/resources/events/test_request_events_resources.py @@ -55,6 +55,7 @@ def test_simple_comment_flow( "links": { "reply": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/reply", # noqa "replies": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/replies", # noqa + "replies_focused": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/replies_focused", "self": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}", # noqa "self_html": f"https://127.0.0.1:5000/requests/{request_id}#commentevent-{comment_id}", }, @@ -105,6 +106,7 @@ def test_simple_comment_flow( "links": { "reply": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/reply", # noqa "replies": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/replies", # noqa + "replies_focused": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/replies_focused", "self": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}", # noqa "self_html": f"https://127.0.0.1:5000/requests/{request_id}#commentevent-{comment_id}", }, diff --git a/tests/resources/files/conftest.py b/tests/resources/files/conftest.py new file mode 100644 index 00000000..9a6d855f --- /dev/null +++ b/tests/resources/files/conftest.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request Files Resource conftest.""" + +import pytest + +from invenio_requests.records.api import RequestEventFormat + + +@pytest.fixture() +def headers_upload(): + """Default headers for uploads.""" + return { + "content-type": "application/octet-stream", + "accept": "application/json", + } + + +@pytest.fixture() +def events_resource_data(): + """Input data for the Request Events Resource (REST body).""" + return { + "payload": { + "content": "This is a comment.", + "format": RequestEventFormat.HTML.value, + } + } diff --git a/tests/resources/files/test_request_files_resources.py b/tests/resources/files/test_request_files_resources.py new file mode 100644 index 00000000..e87a61c7 --- /dev/null +++ b/tests/resources/files/test_request_files_resources.py @@ -0,0 +1,997 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request Files resource tests.""" + +import copy +import hashlib +import re +from io import BytesIO +from uuid import UUID + +from invenio_requests.customizations.event_types import CommentEventType +from invenio_requests.records.api import RequestEvent + + +def assert_api_response_json(expected_json, received_json): + """Assert the REST API response's json.""" + # We don't compare dynamic times at this point + received_json.pop("created", None) + received_json.pop("updated", None) + + # Handle expanded files at payload level (old format) + for file in received_json.get("payload", {}).get("files", []): + if "created" in file: + file.pop("created") + + # Handle files in hits + for hit in received_json.get("hits", {}).get("hits", []): + hit.pop("created") + hit.pop("updated") + + # Remove created from expanded files at the hit level + for file in hit.get("expanded", {}).get("files", []): + file.pop("created", None) + + # Also handle files in children + for child in hit.get("children", []): + child.pop("created", None) + child.pop("updated", None) + # Remove created from expanded files in children + for file in child.get("expanded", {}).get("files", []): + file.pop("created", None) + + assert expected_json == received_json + + +def assert_api_response(response, code, json): + """Assert the REST API response.""" + assert code == response.status_code + assert_api_response_json(json, response.json) + + +def upload_file( + client, + request_id, + key_base, + key_ext, + data_content, + headers_upload, + expected_status_code=200, + expected_json=None, +): + # Upload a file. + response = client.put( + f"/requests/{request_id}/files/upload/{key_base}{key_ext}", + headers=headers_upload, + data=BytesIO(data_content), + ) + + assert expected_status_code == response.status_code + + if expected_status_code != 200 and expected_json is not None: + assert expected_json == response.json + else: + assert "id" in response.json + id_ = response.json["id"] + + # Validate that id_ is a valid UUID. + UUID(id_) + + # Validate that the key has a base32 suffix (length of 10 characters, split every 5 characters) + assert "key" in response.json + unique_key = response.json["key"] + assert re.match(key_base + "-\w{5}-\w{5}" + key_ext, unique_key) + + key = f"{key_base}{key_ext}" + size = len(data_content) + mimetype = "image/png" if key_ext == ".png" else "application/pdf" + + expected_json = { + "id": id_, + "key": unique_key, + "metadata": {"original_filename": key}, + "checksum": f"md5:{hashlib.md5(data_content).hexdigest()}", + "size": size, + "mimetype": mimetype, + "links": { + "self": f"https://127.0.0.1:5000/api/requests/{request_id}/files/{unique_key}", + "content": f"https://127.0.0.1:5000/api/requests/{request_id}/files/{unique_key}/content", + "download_html": f"https://127.0.0.1:5000/requests/{request_id}/files/{unique_key}", + }, + } + + return { + "file_id": id_, + "key": unique_key, + "original_filename": key, + "size": size, + "mimetype": mimetype, + "links": { + "self": f"https://127.0.0.1:5000/api/requests/{request_id}/files/{unique_key}", + "content": f"https://127.0.0.1:5000/api/requests/{request_id}/files/{unique_key}/content", + "download_html": f"https://127.0.0.1:5000/requests/{request_id}/files/{unique_key}", + }, + } + + +def delete_file( + client, + request_id, + key, + headers, + expected_status_code=204, + expected_json=None, +): + # Delete the file. + response = client.delete( + f"/requests/{request_id}/files/{key}", + headers=headers, + ) + assert expected_status_code == response.status_code + assert expected_json == response.json + + +def read_file( + client, + request_id, + key, + data_content, + headers, + expected_status_code=200, + expected_json=None, +): + # Read the file. + response = client.get( + f"/requests/{request_id}/files/{key}/content", + headers=headers, + ) + assert expected_status_code == response.status_code + + if expected_status_code != 200 and expected_json is not None: + assert expected_json == response.json + else: + assert data_content == response.data + + +def get_events_resource_data_with_files(files_details, events_resource_data): + events_resource_data_with_files = copy.deepcopy(events_resource_data) + if files_details: + events_resource_data_with_files["payload"]["files"] = [ + {"file_id": file_details["file_id"]} for file_details in files_details + ] + return events_resource_data_with_files + + +def get_and_assert_timeline_response( + client, + request_id, + comment_id, + files_details, + events_resource_data, + headers, + expected_revision_id, +): + # Refresh index + RequestEvent.index.refresh() + + # Get the timeline. + response = client.get( + f"/requests/{request_id}/timeline?expand=1", + headers=headers, + ) + + expected_status_code = 200 + + expected_json = { + "hits": { + "hits": [ + { + "children": [], + "children_count": 0, + "created_by": { + "user": "1", + }, + "expanded": { + "created_by": { + "active": True, + "blocked_at": None, + "confirmed_at": None, + "email": "user1@example.org", + "id": "1", + "is_current_user": True, + "links": { + "avatar": "https://127.0.0.1:5000/api/users/1/avatar.svg", + "records_html": "https://127.0.0.1:5000/search/records?q=parent.access.owned_by.user:1", + "self": "https://127.0.0.1:5000/api/users/1", + }, + "profile": { + "affiliations": "CERN", + "full_name": "user1", + }, + "username": None, + "verified_at": None, + }, + }, + "id": comment_id, + "links": { + "replies": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/replies", + "replies_focused": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/replies_focused", + "reply": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/reply", + "self": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}", + "self_html": f"https://127.0.0.1:5000/requests/{request_id}#commentevent-{comment_id}", + }, + "parent_id": None, + "payload": { + "content": events_resource_data["payload"]["content"], + "format": events_resource_data["payload"]["format"], + # "files": files_details, + }, + "permissions": { + "can_delete_comment": True, + "can_reply_comment": True, + "can_update_comment": True, + }, + "revision_id": expected_revision_id, + "type": "C", + }, + ], + "total": 1, + }, + "links": { + "self": f"https://127.0.0.1:5000/api/requests/{request_id}/timeline?expand=True&page=1&size=25&sort=oldest", + }, + "page": 1, + "sortBy": "oldest", + } + + # Add files to payload if present (minimal structure with just file_id) + if files_details: + expected_json["hits"]["hits"][0]["payload"]["files"] = [ + {"file_id": file_detail["file_id"]} for file_detail in files_details + ] + # Add expanded files to the expanded field + expected_json["hits"]["hits"][0]["expanded"]["files"] = files_details + + assert_api_response(response, expected_status_code, expected_json) + + +def assert_comment_response( + expected_status_code, + expected_revision_id, + response, + request_id, + comment_id, + files_details, + events_resource_data_with_files, +): + expected_json = { + "id": comment_id, + "payload": { + "content": events_resource_data_with_files["payload"]["content"], + "format": events_resource_data_with_files["payload"]["format"], + }, + "created_by": {"user": "1"}, + "links": { + "reply": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/reply", + "replies": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/replies", + "replies_focused": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}/replies_focused", + "self": f"https://127.0.0.1:5000/api/requests/{request_id}/comments/{comment_id}", + "self_html": f"https://127.0.0.1:5000/requests/{request_id}#commentevent-{comment_id}", + }, + "parent_id": None, + "permissions": { + "can_update_comment": True, + "can_delete_comment": True, + "can_reply_comment": True, + }, + "revision_id": expected_revision_id, + "type": CommentEventType.type_id, + } + + # Add files to payload if present (minimal structure with just file_id) + if files_details: + expected_json["payload"]["files"] = [ + {"file_id": file_detail["file_id"]} for file_detail in files_details + ] + # # Add expanded files to the expanded field + # expected_json["expanded"] = {"files": files_details} + + assert_api_response(response, expected_status_code, expected_json) + + +def submit_comment( + client, + request_id, + files_details, + events_resource_data, + headers, + expected_status_code=201, + expected_json=None, +): + events_resource_data_with_files = get_events_resource_data_with_files( + files_details=files_details, + events_resource_data=events_resource_data, + ) + + response = client.post( + f"/requests/{request_id}/comments", + headers=headers, + json=events_resource_data_with_files, + ) + + assert expected_status_code == response.status_code + + if expected_status_code != 201 and expected_json is not None: + assert expected_json == response.json + else: + comment_id = response.json["id"] + + assert_comment_response( + expected_status_code=expected_status_code, + expected_revision_id=1, + response=response, + request_id=request_id, + comment_id=comment_id, + files_details=files_details, + events_resource_data_with_files=events_resource_data_with_files, + ) + + get_and_assert_timeline_response( + client=client, + request_id=request_id, + comment_id=comment_id, + files_details=files_details, + events_resource_data=events_resource_data, + headers=headers, + expected_revision_id=1, + ) + + return comment_id + + +def update_comment( + client, + request_id, + comment_id, + files_details, + events_resource_data, + headers, +): + events_resource_data_with_files = get_events_resource_data_with_files( + files_details=files_details, + events_resource_data=events_resource_data, + ) + + response = client.put( + f"/requests/{request_id}/comments/{comment_id}", + headers=headers, + json=events_resource_data_with_files, + ) + + assert_comment_response( + expected_status_code=200, + expected_revision_id=2, + response=response, + request_id=request_id, + comment_id=comment_id, + files_details=files_details, + events_resource_data_with_files=events_resource_data_with_files, + ) + + get_and_assert_timeline_response( + client=client, + request_id=request_id, + comment_id=comment_id, + files_details=files_details, + events_resource_data=events_resource_data, + headers=headers, + expected_revision_id=2, + ) + + +def delete_comment( + client, + request_id, + comment_id, + headers, +): + # Delete the file + response = client.delete( + f"/requests/{request_id}/comments/{comment_id}", + headers=headers, + ) + + assert 204 == response.status_code + assert None == response.json + + +def test_update_comment_to_remove_files( + app, + client_logged_as, + example_request, + headers, + headers_upload, + location, + events_resource_data, +): + # Passing the `location` fixture to make sure that a default bucket location is defined. + assert location.default == True + + request_id = example_request.id + + file1_key_base = "screenshot" + file1_key_ext = ".png" + file1_data_content = b"\x89PNG\r\n\x1a\n ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + file2_key_base = "report" + file2_key_ext = ".pdf" + file2_data_content = b"%PDF ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + file3_key_base = "big" + file3_key_ext = ".dat" + # 11 MB file + file3_data_content = b"1234567890A" * 1000 * 1000 + + client = client_logged_as("user1@example.org") + + # Upload first file + file1_details = upload_file( + client=client, + request_id=request_id, + key_base=file1_key_base, + key_ext=file1_key_ext, + data_content=file1_data_content, + headers_upload=headers_upload, + ) + + # Upload second file + file2_details = upload_file( + client=client, + request_id=request_id, + key_base=file2_key_base, + key_ext=file2_key_ext, + data_content=file2_data_content, + headers_upload=headers_upload, + ) + + # Upload third file -> Failure + upload_file( + client=client, + request_id=request_id, + key_base=file3_key_base, + key_ext=file3_key_ext, + data_content=file3_data_content, + headers_upload=headers_upload, + expected_status_code=400, + expected_json={"message": "File size exceeds limit", "status": 400}, + ) + + # Submit comment with file references (only successful uploads) + comment_id = submit_comment( + client=client, + request_id=request_id, + # file3 not included + files_details=[file1_details, file2_details], + events_resource_data=events_resource_data, + headers=headers, + ) + + # Verify first file + read_file( + client=client, + request_id=request_id, + key=file1_details["key"], + data_content=file1_data_content, + headers=headers, + ) + + # Verify second file + read_file( + client=client, + request_id=request_id, + key=file2_details["key"], + data_content=file2_data_content, + headers=headers, + ) + + # Update comment with reduced files array + update_comment( + client=client, + request_id=request_id, + comment_id=comment_id, + # Only second file remains + files_details=[file2_details], + events_resource_data=events_resource_data, + headers=headers, + ) + + # Verify that the first file is not present anymore + read_file( + client=client, + request_id=request_id, + key=file1_details["key"], + data_content=file1_data_content, + headers=headers, + expected_status_code=404, + expected_json={"message": "File not found", "status": 404}, + ) + + # Verify that the second file is still present + read_file( + client=client, + request_id=request_id, + key=file2_details["key"], + data_content=file2_data_content, + headers=headers, + ) + + +def test_update_comment_to_add_files( + app, + client_logged_as, + example_request, + headers, + headers_upload, + location, + events_resource_data, +): + # Passing the `location` fixture to make sure that a default bucket location is defined. + assert location.default == True + + request_id = example_request.id + + file1_key_base = "screenshot" + file1_key_ext = ".png" + file1_data_content = b"\x89PNG\r\n\x1a\n ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + file2_key_base = "report" + file2_key_ext = ".pdf" + file2_data_content = b"%PDF ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + client = client_logged_as("user1@example.org") + + # Submit a comment without files + comment_id = submit_comment( + client=client, + request_id=request_id, + files_details=[], + events_resource_data=events_resource_data, + headers=headers, + ) + + # Upload new files + file1_details = upload_file( + client=client, + request_id=request_id, + key_base=file1_key_base, + key_ext=file1_key_ext, + data_content=file1_data_content, + headers_upload=headers_upload, + ) + + file2_details = upload_file( + client=client, + request_id=request_id, + key_base=file2_key_base, + key_ext=file2_key_ext, + data_content=file2_data_content, + headers_upload=headers_upload, + ) + + # Update comment with new content and files + update_comment( + client=client, + request_id=request_id, + comment_id=comment_id, + files_details=[file1_details, file2_details], + events_resource_data=events_resource_data, + headers=headers, + ) + + # Verify that both files are present + read_file( + client=client, + request_id=request_id, + key=file1_details["key"], + data_content=file1_data_content, + headers=headers, + ) + + read_file( + client=client, + request_id=request_id, + key=file2_details["key"], + data_content=file2_data_content, + headers=headers, + ) + + +def test_file_deleted_between_upload_and_submit( + app, + client_logged_as, + example_request, + headers, + headers_upload, + location, + events_resource_data, +): + # Passing the `location` fixture to make sure that a default bucket location is defined. + assert location.default == True + + request_id = example_request.id + + file1_key_base = "screenshot" + file1_key_ext = ".png" + file1_data_content = b"\x89PNG\r\n\x1a\n ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + # Upload + client = client_logged_as("user1@example.org") + file1_details = upload_file( + client=client, + request_id=request_id, + key_base=file1_key_base, + key_ext=file1_key_ext, + data_content=file1_data_content, + headers_upload=headers_upload, + ) + + # Admin deletes file + client = client_logged_as("admin@example.org") + delete_file( + client=client, + request_id=request_id, + key=file1_details["key"], + headers=headers, + ) + + # Deleting the file again should fail + delete_file( + client=client, + request_id=request_id, + key=file1_details["key"], + headers=headers, + expected_status_code=404, + expected_json={"message": "File not found", "status": 404}, + ) + + # Verify that the file is not present anymore + read_file( + client=client, + request_id=request_id, + key=file1_details["key"], + data_content=file1_data_content, + headers=headers, + expected_status_code=404, + expected_json={"message": "File not found", "status": 404}, + ) + + # User submits + client = client_logged_as("user1@example.org") + file1_id = file1_details["file_id"] + submit_comment( + client=client, + request_id=request_id, + files_details=[file1_details], + events_resource_data=events_resource_data, + headers=headers, + expected_status_code=400, + expected_json={ + "message": "A validation error occurred.", + "status": 400, + "errors": [ + { + "field": "payload.files[0]", + "messages": [f"File {file1_id} not found."], + }, + ], + }, + ) + + +def test_delete_comment_with_files( + app, + client_logged_as, + example_request, + headers, + headers_upload, + location, + events_resource_data, +): + # Passing the `location` fixture to make sure that a default bucket location is defined. + assert location.default == True + + request_id = example_request.id + + file1_key_base = "screenshot" + file1_key_ext = ".png" + file1_data_content = b"\x89PNG\r\n\x1a\n ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + file2_key_base = "report" + file2_key_ext = ".pdf" + file2_data_content = b"%PDF ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + client = client_logged_as("user1@example.org") + + # Upload first file + file1_details = upload_file( + client=client, + request_id=request_id, + key_base=file1_key_base, + key_ext=file1_key_ext, + data_content=file1_data_content, + headers_upload=headers_upload, + ) + + # Upload second file + file2_details = upload_file( + client=client, + request_id=request_id, + key_base=file2_key_base, + key_ext=file2_key_ext, + data_content=file2_data_content, + headers_upload=headers_upload, + ) + + # Submit comment with file references + comment_id = submit_comment( + client=client, + request_id=request_id, + files_details=[file1_details, file2_details], + events_resource_data=events_resource_data, + headers=headers, + ) + + # Delete the comment + delete_comment( + client=client, + request_id=request_id, + comment_id=comment_id, + headers=headers, + ) + + # Verify that the files are not present anymore + read_file( + client=client, + request_id=request_id, + key=file1_details["key"], + data_content=file1_data_content, + headers=headers, + expected_status_code=404, + expected_json={"message": "File not found", "status": 404}, + ) + + read_file( + client=client, + request_id=request_id, + key=file2_details["key"], + data_content=file2_data_content, + headers=headers, + expected_status_code=404, + expected_json={"message": "File not found", "status": 404}, + ) + + +def test_comment_with_files_expansion( + app, + client_logged_as, + example_request, + headers, + headers_upload, + location, + events_resource_data, +): + """Test that comment file details are properly expanded in timeline.""" + # Passing the `location` fixture to make sure that a default bucket location is defined. + assert location.default == True + + request_id = example_request.id + + file1_key_base = "screenshot" + file1_key_ext = ".png" + file1_data_content = b"\x89PNG\r\n\x1a\n ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + file2_key_base = "report" + file2_key_ext = ".pdf" + file2_data_content = b"%PDF ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + client = client_logged_as("user1@example.org") + + # Upload first file + file1_details = upload_file( + client=client, + request_id=request_id, + key_base=file1_key_base, + key_ext=file1_key_ext, + data_content=file1_data_content, + headers_upload=headers_upload, + ) + + # Upload second file + file2_details = upload_file( + client=client, + request_id=request_id, + key_base=file2_key_base, + key_ext=file2_key_ext, + data_content=file2_data_content, + headers_upload=headers_upload, + ) + + # Create comment with file references + events_resource_data_with_files = get_events_resource_data_with_files( + files_details=[file1_details, file2_details], + events_resource_data=events_resource_data, + ) + + response = client.post( + f"/requests/{request_id}/comments", + headers=headers, + json=events_resource_data_with_files, + ) + + assert 201 == response.status_code + comment_id = response.json["id"] + + # Test: Verify file details are expanded in timeline + get_and_assert_timeline_response( + client=client, + request_id=request_id, + comment_id=comment_id, + files_details=[file1_details, file2_details], + events_resource_data=events_resource_data, + headers=headers, + expected_revision_id=1, + ) + + +def test_comment_with_reply_files_expansion( + app, + client_logged_as, + example_request, + headers, + headers_upload, + location, + events_resource_data, +): + """Test that comment and reply file details are properly expanded in timeline.""" + # Passing the `location` fixture to make sure that a default bucket location is defined. + assert location.default == True + + request_id = example_request.id + + # Files for parent comment + parent_file1_key_base = "parent-screenshot" + parent_file1_key_ext = ".png" + parent_file1_data_content = b"\x89PNG\r\n\x1a\n PARENT FILE CONTENT" + + # Files for child reply + child_file1_key_base = "child-document" + child_file1_key_ext = ".pdf" + child_file1_data_content = b"%PDF CHILD FILE CONTENT" + + client = client_logged_as("user1@example.org") + + # Upload file for parent comment + parent_file1_details = upload_file( + client=client, + request_id=request_id, + key_base=parent_file1_key_base, + key_ext=parent_file1_key_ext, + data_content=parent_file1_data_content, + headers_upload=headers_upload, + ) + + # Upload file for child reply + child_file1_details = upload_file( + client=client, + request_id=request_id, + key_base=child_file1_key_base, + key_ext=child_file1_key_ext, + data_content=child_file1_data_content, + headers_upload=headers_upload, + ) + + # Create parent comment with file + parent_events_data = get_events_resource_data_with_files( + files_details=[parent_file1_details], + events_resource_data=events_resource_data, + ) + + response = client.post( + f"/requests/{request_id}/comments", + headers=headers, + json=parent_events_data, + ) + + assert 201 == response.status_code + parent_comment_id = response.json["id"] + + # Create child reply with file + child_events_data = get_events_resource_data_with_files( + files_details=[child_file1_details], + events_resource_data=events_resource_data, + ) + + response = client.post( + f"/requests/{request_id}/comments/{parent_comment_id}/reply", + headers=headers, + json=child_events_data, + ) + + assert 201 == response.status_code + child_comment_id = response.json["id"] + + # Refresh index + RequestEvent.index.refresh() + + # Get the timeline with expansion + response = client.get( + f"/requests/{request_id}/timeline?expand=1", + headers=headers, + ) + + assert 200 == response.status_code + timeline_data = response.json + + # Find the parent comment in the timeline + parent_hit = None + for hit in timeline_data["hits"]["hits"]: + if hit["id"] == parent_comment_id: + parent_hit = hit + break + + assert parent_hit is not None, "Parent comment not found in timeline" + + # Verify parent has expanded files + assert "expanded" in parent_hit + assert "files" in parent_hit["expanded"] + assert len(parent_hit["expanded"]["files"]) == 1 + parent_expanded_file = parent_hit["expanded"]["files"][0] + assert parent_expanded_file["file_id"] == parent_file1_details["file_id"] + assert parent_expanded_file["key"] == parent_file1_details["key"] + assert ( + parent_expanded_file["original_filename"] + == parent_file1_details["original_filename"] + ) + assert parent_expanded_file["size"] == parent_file1_details["size"] + assert parent_expanded_file["mimetype"] == parent_file1_details["mimetype"] + assert "links" in parent_expanded_file + + # Verify parent payload.files has minimal structure + assert "files" in parent_hit["payload"] + assert len(parent_hit["payload"]["files"]) == 1 + assert parent_hit["payload"]["files"][0] == { + "file_id": parent_file1_details["file_id"] + } + + # Verify parent has children + assert "children" in parent_hit + assert len(parent_hit["children"]) == 1 + child_hit = parent_hit["children"][0] + + # Verify child has expanded files + assert "expanded" in child_hit + assert "files" in child_hit["expanded"] + assert len(child_hit["expanded"]["files"]) == 1 + child_expanded_file = child_hit["expanded"]["files"][0] + assert child_expanded_file["file_id"] == child_file1_details["file_id"] + assert child_expanded_file["key"] == child_file1_details["key"] + assert ( + child_expanded_file["original_filename"] + == child_file1_details["original_filename"] + ) + assert child_expanded_file["size"] == child_file1_details["size"] + assert child_expanded_file["mimetype"] == child_file1_details["mimetype"] + assert "links" in child_expanded_file + + # Verify child payload.files has minimal structure + assert "files" in child_hit["payload"] + assert len(child_hit["payload"]["files"]) == 1 + assert child_hit["payload"]["files"][0] == { + "file_id": child_file1_details["file_id"] + } diff --git a/tests/resources/requests/test_requests_resources.py b/tests/resources/requests/test_requests_resources.py index cb7d718c..32d5ccd4 100644 --- a/tests/resources/requests/test_requests_resources.py +++ b/tests/resources/requests/test_requests_resources.py @@ -150,6 +150,7 @@ def test_simple_request_flow(app, client_logged_as, headers, example_request): "is_open": False, "is_closed": False, "expires_at": None, + "files": {"enabled": True}, "is_expired": False, "links": { "self": f"https://127.0.0.1:5000/api/requests/{id_}", diff --git a/tests/test_alembic.py b/tests/test_alembic.py index 96ef1413..114fb7b3 100644 --- a/tests/test_alembic.py +++ b/tests/test_alembic.py @@ -27,6 +27,7 @@ def test_alembic(base_app, database): tables = [x for x in db.metadata.tables] assert "request_metadata" in tables assert "request_events" in tables + assert "request_files" in tables # Check that Alembic agrees that there's no further tables to create. assert len(ext.alembic.compare_metadata()) == 0 diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py new file mode 100644 index 00000000..bc7208e8 --- /dev/null +++ b/tests/ui/conftest.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Pytest configuration for the UI application. + +See https://pytest-invenio.readthedocs.io/ for documentation on which test +fixtures are available. +""" + +from io import BytesIO + +import pytest +from flask_webpackext.manifest import ( + JinjaManifest, + JinjaManifestEntry, + JinjaManifestLoader, +) +from invenio_app.factory import create_ui + +from invenio_requests.proxies import current_requests + + +# +# Mock the webpack manifest to avoid having to compile the full assets. +# +class MockJinjaManifest(JinjaManifest): + """Mock manifest.""" + + def __getitem__(self, key): + """Get a manifest entry.""" + return JinjaManifestEntry(key, [key]) + + def __getattr__(self, name): + """Get a manifest entry.""" + return JinjaManifestEntry(name, [name]) + + +class MockManifestLoader(JinjaManifestLoader): + """Manifest loader creating a mocked manifest.""" + + def load(self, filepath): + """Load the manifest.""" + return MockJinjaManifest() + + +@pytest.fixture(scope="module") +def app_config(app_config): + """Create test app.""" + app_config["WEBPACKEXT_MANIFEST_LOADER"] = MockManifestLoader + return app_config + + +@pytest.fixture(scope="module") +def create_app(): + """Create test app.""" + return create_ui + + +@pytest.fixture() +def example_request_file(example_request, identity_simple, location): + """Example request file.""" + # Passing the `location` fixture to make sure that a default bucket location is defined. + assert location.default == True + + key = "filename.ext" + data = b"test" + length = 4 + + request_files_service = current_requests.request_files_service + file_details = request_files_service.create_file( + identity=identity_simple, + id_=example_request.id, + key=key, + stream=BytesIO(data), + content_length=length, + ) + + return { + "key": file_details["key"], + "data": data, + } diff --git a/tests/ui/test_request_ui_files_resources.py b/tests/ui/test_request_ui_files_resources.py new file mode 100644 index 00000000..3cda2faa --- /dev/null +++ b/tests/ui/test_request_ui_files_resources.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 CERN. +# +# 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. + +"""Request UI Files resource tests.""" + + +def read_file(client, request_id, key, data_content, headers): + # Read the file (UI HTML/Download endpoint). + response = client.get( + f"/requests/{request_id}/files/{key}", + headers=headers, + ) + assert 200 == response.status_code + assert data_content == response.data + + +def test_file_read_ui_url( + app, client_logged_as, headers, example_request, example_request_file +): + + client = client_logged_as("user1@example.org") + + read_file( + client, + example_request.id, + example_request_file["key"], + example_request_file["data"], + headers, + )
{{ _("'@{user_name}' replied on comment:").format(user_name=event_creator_name, parent_link=parent_comment_link) | safe }}{{ _("'@{user_name}' replied on comment:").format(user_name=event_creator_name, reply_link=reply_link) | safe }}
{{ request_event_content }}