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 -%}
- | {{ _("'@{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 }} |
@@ -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,
+ )