Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion invenio_requests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions invenio_requests/alembic/1763728177_create_request_files_table.py
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ 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) {
super(props);
const {
requestsApi,
requestEventsApi,
request,
defaultQueryParams,
defaultReplyQueryParams,
dataset: { request, defaultQueryParams, defaultReplyQueryParams },
} = this.props;

const defaultRequestsApi = new InvenioRequestsAPI(
new RequestLinksExtractor(request)
);
Expand All @@ -46,37 +46,34 @@ export class InvenioRequestsApp extends Component {
}

render() {
const { overriddenCmps, userAvatar, permissions, config } = this.props;
const { overriddenCmps, dataset } = this.props;
const { userAvatar, permissions, config } = dataset;

return (
<OverridableContext.Provider value={overriddenCmps}>
<Provider store={this.store}>
<Request userAvatar={userAvatar} permissions={permissions} config={config} />
<DatasetContext.Provider value={dataset}>
<Request
userAvatar={userAvatar}
permissions={permissions}
config={config}
/>
</DatasetContext.Provider>
</Provider>
</OverridableContext.Provider>
);
}
}

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,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,12 @@ RequestEventInnerContainer.defaultProps = {
isEvent: false,
};

export const RequestEventAvatarContainer = ({ src, hasLine, ...uiProps }) => (
<div className={`requests-avatar-container${hasLine ? " has-line" : ""}`}>
export const RequestEventAvatarContainer = ({ src, hasLine, lineFade, ...uiProps }) => (
<div
className={`requests-avatar-container${hasLine ? " has-line" : ""}${
lineFade ? " line-fade" : ""
}`}
>
{src && <Image src={src} rounded avatar {...uiProps} />}
{!src && <Icon size="large" name="user circle outline" />}
</div>
Expand All @@ -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 }) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -54,6 +54,7 @@ class TimelineActionEvent extends Component {
<b>{user}</b>
<Feed.Date>
<TimelineEventBody
collapsible={false}
payload={{ ...event.payload, content: eventContent }}
/>{" "}
{toRelativeTime(event.created, i18next.language)}
Expand Down
Loading
Loading