Skip to content

Commit 079a5ed

Browse files
ptamaritzzacharo
andcommitted
feature: comments files (#571)
* feat(comment-file): file upload delete read * feat(comment-file): file read UI URL * feat(comment-file): comment-file association * feat(comment-file): allow inline images in comments * feat(comment-file): file details expansion Co-authored-by: Pablo Tamarit <pablo.tamarit@cern.ch> * feat(comment-file): user interface * feat(comment-file): lazy files initialization * fix: timeline event body forward ref (displayName) --------- Co-authored-by: zzacharo <zacharias.zacharodimos@cern.ch>
1 parent 4ba940c commit 079a5ed

59 files changed

Lines changed: 2652 additions & 76 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

invenio_requests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .proxies import (
1515
current_event_type_registry,
1616
current_events_service,
17+
current_request_files_service,
1718
current_request_type_registry,
1819
current_requests,
1920
current_requests_resource,
@@ -26,6 +27,7 @@
2627
"__version__",
2728
"current_event_type_registry",
2829
"current_events_service",
30+
"current_request_files_service",
2931
"current_request_type_registry",
3032
"current_requests_resource",
3133
"current_requests_service",
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#
2+
# Copyright (C) 2025 CERN.
3+
#
4+
# Invenio-Requests is free software; you can redistribute it and/or modify it
5+
# under the terms of the MIT License; see LICENSE file for more details.
6+
7+
"""Create request files table."""
8+
9+
import sqlalchemy as sa
10+
import sqlalchemy_utils
11+
from alembic import op
12+
from sqlalchemy.dialects import mysql, postgresql
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "1763728177"
16+
down_revision = "1759321170"
17+
branch_labels = ()
18+
depends_on = "8ae99b034410"
19+
20+
21+
def upgrade():
22+
"""Upgrade database."""
23+
op.create_table(
24+
"request_files",
25+
sa.Column("id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=False),
26+
sa.Column(
27+
"json",
28+
sa.JSON()
29+
.with_variant(sqlalchemy_utils.types.json.JSONType(), "mysql")
30+
.with_variant(
31+
postgresql.JSONB(none_as_null=True, astext_type=sa.Text()), "postgresql"
32+
)
33+
.with_variant(sqlalchemy_utils.types.json.JSONType(), "sqlite"),
34+
nullable=True,
35+
),
36+
sa.Column("version_id", sa.Integer(), nullable=False),
37+
sa.Column(
38+
"created",
39+
sa.DateTime().with_variant(mysql.DATETIME(fsp=6), "mysql"),
40+
nullable=False,
41+
),
42+
sa.Column(
43+
"updated",
44+
sa.DateTime().with_variant(mysql.DATETIME(fsp=6), "mysql"),
45+
nullable=False,
46+
),
47+
sa.Column(
48+
"key",
49+
sa.Text().with_variant(mysql.VARCHAR(length=255), "mysql"),
50+
nullable=False,
51+
),
52+
sa.Column("record_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=False),
53+
sa.Column(
54+
"object_version_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True
55+
),
56+
sa.ForeignKeyConstraint(
57+
["object_version_id"],
58+
["files_object.version_id"],
59+
name=op.f("fk_request_files_object_version_id_files_object"),
60+
ondelete="RESTRICT",
61+
),
62+
sa.ForeignKeyConstraint(
63+
["record_id"],
64+
["request_metadata.id"],
65+
name=op.f("fk_request_files_record_id_request_metadata"),
66+
ondelete="RESTRICT",
67+
),
68+
sa.PrimaryKeyConstraint("id", name=op.f("pk_request_files")),
69+
)
70+
op.create_index(
71+
op.f("ix_request_files_object_version_id"),
72+
"request_files",
73+
["object_version_id"],
74+
unique=False,
75+
)
76+
op.create_index(
77+
op.f("ix_request_files_record_id"), "request_files", ["record_id"], unique=False
78+
)
79+
op.create_index(
80+
"uidx_request_files_record_id_key",
81+
"request_files",
82+
["record_id", "key"],
83+
unique=True,
84+
)
85+
op.add_column(
86+
"request_metadata",
87+
sa.Column("bucket_id", sqlalchemy_utils.types.uuid.UUIDType(), nullable=True),
88+
)
89+
op.create_index(
90+
op.f("ix_request_metadata_bucket_id"),
91+
"request_metadata",
92+
["bucket_id"],
93+
unique=False,
94+
)
95+
op.create_foreign_key(
96+
op.f("fk_request_metadata_bucket_id_files_bucket"),
97+
"request_metadata",
98+
"files_bucket",
99+
["bucket_id"],
100+
["id"],
101+
ondelete="RESTRICT",
102+
)
103+
104+
105+
def downgrade():
106+
"""Downgrade database."""
107+
op.drop_index(op.f("ix_request_metadata_bucket_id"), table_name="request_metadata")
108+
op.drop_constraint(
109+
op.f("fk_request_metadata_bucket_id_files_bucket"),
110+
"request_metadata",
111+
type_="foreignkey",
112+
)
113+
op.drop_column("request_metadata", "bucket_id")
114+
op.drop_index("uidx_request_files_record_id_key", table_name="request_files")
115+
op.drop_index(op.f("ix_request_files_record_id"), table_name="request_files")
116+
op.drop_index(
117+
op.f("ix_request_files_object_version_id"), table_name="request_files"
118+
)
119+
op.drop_table("request_files")

invenio_requests/alembic/74b23178bfbe_change_datetime_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
# revision identifiers, used by Alembic.
1717
revision = "74b23178bfbe"
18-
down_revision = "1759321170"
18+
down_revision = "1763728177"
1919
branch_labels = ()
2020
depends_on = None
2121

invenio_requests/assets/semantic-ui/js/invenio_requests/api/InvenioRequestApi.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,14 @@ export class InvenioRequestsAPI {
133133
});
134134
};
135135

136-
performAction = async (action, commentContent = null) => {
136+
performAction = async (action, commentContent = null, files = []) => {
137137
let payload = {};
138138
if (!_isEmpty(commentContent)) {
139139
payload = {
140140
payload: {
141141
content: commentContent,
142142
format: "html",
143+
files: files,
143144
},
144145
};
145146
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This file is part of InvenioRequests
2+
// Copyright (C) 2025 CERN.
3+
//
4+
// Invenio RDM Records is free software; you can redistribute it and/or modify it
5+
// under the terms of the MIT License; see LICENSE file for more details.
6+
import { http } from "react-invenio-forms";
7+
8+
export class InvenioRequestFilesApi {
9+
baseUrl = "/api/requests";
10+
11+
/**
12+
* Upload a file linked to a request.
13+
*
14+
* @param {string} requestId - Request ID
15+
* @param {string} filename - Original filename
16+
* @param {object} payload - File
17+
* @param {object} options - Custom options
18+
*/
19+
async uploadFile(requestId, filename, payload, options) {
20+
options = options || {};
21+
const headers = {
22+
"Content-Type": "application/octet-stream",
23+
};
24+
return http.put(`${this.baseUrl}/${requestId}/files/upload/${filename}`, payload, {
25+
headers: headers,
26+
...options,
27+
});
28+
}
29+
30+
/**
31+
* Delete a file linked to a request.
32+
*
33+
* @param {string} requestId - Request ID
34+
* @param {string} fileKey - Unique filename (key)
35+
* @param {object} options - Custom options
36+
*/
37+
async deleteFile(requestId, fileKey, options) {
38+
options = options || {};
39+
const headers = {
40+
"Content-Type": "application/octet-stream",
41+
};
42+
return http.delete(`${this.baseUrl}/${requestId}/files/${fileKey}`, {
43+
headers: headers,
44+
...options,
45+
});
46+
}
47+
}

invenio_requests/assets/semantic-ui/js/invenio_requests/api/serializers.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
//
44
// Invenio RDM Records is free software; you can redistribute it and/or modify it
55
// under the terms of the MIT License; see LICENSE file for more details.
6-
export const payloadSerializer = (content, format) => ({
6+
export const payloadSerializer = (content, format, files) => ({
77
payload: {
88
content,
99
format,
10+
files: files.map((file) => ({
11+
file_id: file.file_id,
12+
})),
1013
},
1114
});
1215

invenio_requests/assets/semantic-ui/js/invenio_requests/components/TimelineEventBody.js

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
88
import PropTypes from "prop-types";
99
import Overridable from "react-overridable";
1010
import { Button, Popup, ButtonGroup } from "semantic-ui-react";
11+
import { FilesList } from "react-invenio-forms";
1112
import { i18next } from "@translations/invenio_requests/i18next";
1213

1314
const TimelineEventBody = ({ payload, quoteReply, collapsible, expandedByDefault }) => {
@@ -41,42 +42,59 @@ TimelineEventBody.defaultProps = {
4142
expandedByDefault: false,
4243
};
4344

44-
const TimelineEventBodyRender = React.forwardRef(({
45-
refInner,
46-
isOverflowing,
47-
expanded,
48-
collapsible,
49-
toggleCollapsed,
50-
content,
51-
format,
52-
files,
53-
}, ref) => {
54-
const getCollapsibleClass = () => {
55-
if (!isOverflowing) return "";
56-
return expanded || !collapsible ? "expanded" : "overflowing";
57-
};
45+
const TimelineEventBodyRender = React.forwardRef(
46+
(
47+
{
48+
refInner,
49+
isOverflowing,
50+
expanded,
51+
collapsible,
52+
toggleCollapsed,
53+
content,
54+
format,
55+
files,
56+
},
57+
ref
58+
) => {
59+
const getCollapsibleClass = () => {
60+
if (!isOverflowing) return "";
61+
return expanded || !collapsible ? "expanded" : "overflowing";
62+
};
5863

59-
return (
60-
<span ref={ref} className={`${collapsible ? "collapsible-comment" : ""} ${getCollapsibleClass()}`}>
61-
<span ref={refInner} className={collapsible ? "collapsible-comment-inner" : ""}>
62-
{format === "html" ? (
63-
<span dangerouslySetInnerHTML={{ __html: content }} />
64-
) : (
65-
content
66-
)}
67-
{isOverflowing && collapsible && (
68-
<button
69-
type="button"
70-
className="ui tiny button text-only show-more"
71-
onClick={toggleCollapsed}
64+
return (
65+
<>
66+
<span
67+
ref={ref}
68+
className={`${
69+
collapsible ? "collapsible-comment" : ""
70+
} ${getCollapsibleClass()}`}
71+
>
72+
<span
73+
ref={refInner}
74+
className={collapsible ? "collapsible-comment-inner" : ""}
7275
>
73-
{expanded ? i18next.t("Show less") : i18next.t("Show more")}
74-
</button>
75-
)}
76-
</span>
77-
</span>
78-
);
79-
});
76+
{format === "html" ? (
77+
<span dangerouslySetInnerHTML={{ __html: content }} />
78+
) : (
79+
content
80+
)}
81+
{isOverflowing && collapsible && (
82+
<button
83+
type="button"
84+
className="ui tiny button text-only show-more"
85+
onClick={toggleCollapsed}
86+
>
87+
{expanded ? i18next.t("Show less") : i18next.t("Show more")}
88+
</button>
89+
)}
90+
</span>
91+
</span>
92+
{files !== undefined && <FilesList files={files} />}
93+
</>
94+
);
95+
}
96+
);
97+
TimelineEventBodyRender.displayName = "TimelineEventBodyRender";
8098

8199
TimelineEventBodyRender.propTypes = {
82100
refInner: PropTypes.instanceOf(Element).isRequired,
@@ -86,6 +104,7 @@ TimelineEventBodyRender.propTypes = {
86104
toggleCollapsed: PropTypes.func.isRequired,
87105
content: PropTypes.string.isRequired,
88106
format: PropTypes.string,
107+
files: PropTypes.array.isRequired,
89108
};
90109

91110
TimelineEventBodyRender.defaultProps = {
@@ -171,7 +190,7 @@ const TimelineEventBodyContainer = ({
171190
window.invenio?.onSearchResultsRendered();
172191
}, []);
173192

174-
const { format, content, event } = payload;
193+
const { format, content, files, event } = payload;
175194

176195
if (!quoteReply) {
177196
return (
@@ -183,6 +202,7 @@ const TimelineEventBodyContainer = ({
183202
collapsible={collapsible}
184203
toggleCollapsed={toggleCollapsed}
185204
content={content}
205+
files={files}
186206
/>
187207
);
188208
}
@@ -214,6 +234,7 @@ const TimelineEventBodyContainer = ({
214234
toggleCollapsed={toggleCollapsed}
215235
content={content}
216236
format={format}
237+
files={files}
217238
/>
218239
}
219240
basic

0 commit comments

Comments
 (0)