Skip to content

Commit c1ee20b

Browse files
committed
feat(events): deep links
* Added a "Copy link" button to events/comments in the UI, which copies the current URL with a hash containing the event ID. * When TimelineFeed is mounted, it sees if there's a relevant hash in the URL, and requests the server to return the page that contains that event (instead of just requesting the first page). * Added an argument to the request event `search` route to return the page containing a given event instead of a specific numbered page. The actual page number chosen is returned by the server (see inveniosoftware/invenio-records-resources#656). This is calculated by counting the number of records older than the specified one and dividing by the page size.
1 parent e4ad71d commit c1ee20b

File tree

9 files changed

+150
-23
lines changed

9 files changed

+150
-23
lines changed

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// under the terms of the MIT License; see LICENSE file for more details.
77

88
import PropTypes from "prop-types";
9-
import React from "react";
9+
import React, { forwardRef } from "react";
1010
import { Image } from "react-invenio-forms";
1111
import { Container, Feed, Icon } from "semantic-ui-react";
1212

@@ -26,18 +26,31 @@ RequestsFeed.defaultProps = {
2626
children: null,
2727
};
2828

29-
export const RequestEventItem = ({ children }) => (
30-
<div className="requests-event-item">
31-
<div className="requests-event-container">{children}</div>
32-
</div>
33-
);
29+
export const RequestEventItem = forwardRef(function RequestEventItem(
30+
{ id, children, selected },
31+
ref
32+
) {
33+
return (
34+
<div
35+
className={`requests-event-item${selected ? " selected" : ""}`}
36+
id={id}
37+
ref={ref}
38+
>
39+
<div className="requests-event-container">{children}</div>
40+
</div>
41+
);
42+
});
3443

3544
RequestEventItem.propTypes = {
45+
id: PropTypes.string,
3646
children: PropTypes.node,
47+
selected: PropTypes.bool,
3748
};
3849

3950
RequestEventItem.defaultProps = {
51+
id: null,
4052
children: null,
53+
selected: false,
4154
};
4255

4356
export const RequestEventInnerContainer = ({ children, isEvent }) => (

invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/TimelineFeed.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Pagination } from "../components/Pagination";
1717
import RequestsFeed from "../components/RequestsFeed";
1818
import { TimelineCommentEditor } from "../timelineCommentEditor";
1919
import { TimelineCommentEventControlled } from "../timelineCommentEventControlled";
20+
import { getEventIdFromUrl } from "../timelineEvents/utils";
2021

2122
class TimelineFeed extends Component {
2223
constructor(props) {
@@ -30,7 +31,9 @@ class TimelineFeed extends Component {
3031

3132
componentDidMount() {
3233
const { getTimelineWithRefresh } = this.props;
33-
getTimelineWithRefresh();
34+
35+
// Check if an event ID is included in the hash
36+
getTimelineWithRefresh(getEventIdFromUrl());
3437
}
3538

3639
async componentDidUpdate(prevProps) {

invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
import TimelineFeedComponent from "./TimelineFeed";
1414

1515
const mapDispatchToProps = (dispatch) => ({
16-
getTimelineWithRefresh: () => dispatch(getTimelineWithRefresh()),
16+
getTimelineWithRefresh: (includeEventId) =>
17+
dispatch(getTimelineWithRefresh(includeEventId)),
1718
timelineStopRefresh: () => dispatch(clearTimelineInterval()),
1819
setPage: (page) => dispatch(setPage(page)),
1920
});

invenio_requests/assets/semantic-ui/js/invenio_requests/timeline/state/actions.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class intervalManager {
2525
}
2626
}
2727

28-
export const fetchTimeline = (loadingState = true) => {
28+
export const fetchTimeline = (includeEventId = undefined, loadingState = true) => {
2929
return async (dispatch, getState, config) => {
3030
const state = getState();
3131
const { size, page, data: timelineData } = state.timeline;
@@ -44,6 +44,7 @@ export const fetchTimeline = (loadingState = true) => {
4444
size: size,
4545
page: page,
4646
sort: "oldest",
47+
include_event_id: includeEventId,
4748
});
4849

4950
// Check if timeline has more events than the current state
@@ -62,6 +63,15 @@ export const fetchTimeline = (loadingState = true) => {
6263
}
6364
}
6465

66+
if (response.data.page !== page) {
67+
// If a different page was returned (e.g. a specific event ID was requested) we need to update it.
68+
// This will _not_ trigger a reload of the timeline.
69+
dispatch({
70+
type: CHANGE_PAGE,
71+
payload: response.data.page,
72+
});
73+
}
74+
6575
dispatch({
6676
type: SUCCESS,
6777
payload: response.data,
@@ -99,12 +109,12 @@ const timelineReload = (dispatch, getState, config) => {
99109

100110
if (concurrentRequests) return;
101111

102-
dispatch(fetchTimeline(false));
112+
dispatch(fetchTimeline(undefined, false));
103113
};
104114

105-
export const getTimelineWithRefresh = () => {
115+
export const getTimelineWithRefresh = (includeEventId) => {
106116
return async (dispatch, getState, config) => {
107-
dispatch(fetchTimeline(true));
117+
dispatch(fetchTimeline(includeEventId, true));
108118
dispatch(setTimelineInterval());
109119
};
110120
};

invenio_requests/assets/semantic-ui/js/invenio_requests/timelineEvents/TimelineCommentEvent.js

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { i18next } from "@translations/invenio_requests/i18next";
88
import PropTypes from "prop-types";
9-
import React, { Component } from "react";
9+
import React, { Component, createRef } from "react";
1010
import { Image } from "react-invenio-forms";
1111
import Overridable from "react-overridable";
1212
import { Container, Dropdown, Feed, Icon } from "semantic-ui-react";
@@ -16,16 +16,34 @@ import { RichEditor } from "react-invenio-forms";
1616
import RequestsFeed from "../components/RequestsFeed";
1717
import { TimelineEventBody } from "../components/TimelineEventBody";
1818
import { toRelativeTime } from "react-invenio-forms";
19+
import { copyUrlForEvent, getEventIdFromUrl } from "./utils";
1920

2021
class TimelineCommentEvent extends Component {
2122
constructor(props) {
2223
super(props);
2324

2425
const { event } = props;
2526

27+
const {
28+
event: { id: thisEventId },
29+
} = this.props;
30+
const urlEventId = getEventIdFromUrl();
31+
const isSelected = urlEventId === thisEventId;
32+
2633
this.state = {
2734
commentContent: event?.payload?.content,
35+
isSelected,
2836
};
37+
this.ref = createRef(null);
38+
}
39+
40+
componentDidMount() {
41+
const { isSelected } = this.state;
42+
if (isSelected && this.ref.current) {
43+
// We need to manually focus the element since it will have been loaded after the initial page load.
44+
this.ref.current.scrollIntoView({ behaviour: "smooth" });
45+
this.ref.current.focus();
46+
}
2947
}
3048

3149
eventToType = ({ type, payload }) => {
@@ -41,6 +59,13 @@ class TimelineCommentEvent extends Component {
4159
}
4260
};
4361

62+
copyLink() {
63+
const {
64+
event: { id: eventId },
65+
} = this.props;
66+
copyUrlForEvent(eventId);
67+
}
68+
4469
render() {
4570
const {
4671
isLoading,
@@ -51,7 +76,7 @@ class TimelineCommentEvent extends Component {
5176
deleteComment,
5277
toggleEditMode,
5378
} = this.props;
54-
const { commentContent } = this.state;
79+
const { commentContent, isSelected } = this.state;
5580

5681
const commentHasBeenEdited = event?.revision_id > 1 && event?.payload;
5782

@@ -75,7 +100,11 @@ class TimelineCommentEvent extends Component {
75100
}
76101
return (
77102
<Overridable id={`TimelineEvent.layout.${this.eventToType(event)}`} event={event}>
78-
<RequestsFeed.Item>
103+
<RequestsFeed.Item
104+
id={`event-${event.id}`}
105+
ref={this.ref}
106+
selected={isSelected}
107+
>
79108
<RequestsFeed.Content>
80109
{userAvatar}
81110
<RequestsFeed.Event>
@@ -88,6 +117,9 @@ class TimelineCommentEvent extends Component {
88117
aria-label={i18next.t("Actions")}
89118
>
90119
<Dropdown.Menu>
120+
<Dropdown.Item onClick={() => this.copyLink()}>
121+
{i18next.t("Copy link")}
122+
</Dropdown.Item>
91123
{canUpdate && (
92124
<Dropdown.Item onClick={() => toggleEditMode()}>
93125
{i18next.t("Edit")}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// This file is part of InvenioRequests
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+
export const copyUrlForEvent = (eventId) => {
8+
const currentUrl = new URL(window.location.href);
9+
currentUrl.hash = `event-${eventId}`;
10+
navigator.clipboard.writeText(currentUrl.toString());
11+
};
12+
13+
export const getEventIdFromUrl = () => {
14+
const currentUrl = new URL(window.location.href);
15+
const hash = currentUrl.hash;
16+
let eventId = null;
17+
if (hash.startsWith("#event-")) {
18+
eventId = hash.substring(7);
19+
}
20+
return eventId;
21+
};

invenio_requests/resources/events/config.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,19 @@
1010

1111
"""RequestEvent Resource Configuration."""
1212

13-
from invenio_records_resources.resources import RecordResourceConfig
13+
from invenio_records_resources.resources import (
14+
RecordResourceConfig,
15+
SearchRequestArgsSchema,
16+
)
1417
from marshmallow import fields
1518

1619

20+
class RequestCommentsSearchRequestArgsSchema(SearchRequestArgsSchema):
21+
"""Add parameter to parse tags."""
22+
23+
include_event_id = fields.UUID()
24+
25+
1726
class RequestCommentsResourceConfig(RecordResourceConfig):
1827
"""Request Events resource configuration."""
1928

@@ -37,6 +46,8 @@ class RequestCommentsResourceConfig(RecordResourceConfig):
3746
"comment_id": fields.Str(),
3847
}
3948

49+
request_search_args = RequestCommentsSearchRequestArgsSchema
50+
4051
response_handlers = {
4152
"application/vnd.inveniordm.v1+json": RecordResourceConfig.response_handlers[
4253
"application/json"

invenio_requests/resources/events/resource.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,14 @@ def search(self):
130130
"""Perform a search over EVENTS.
131131
132132
Its primary purpose is as a batch read of events i.e. the timeline.
133+
134+
By specifying `include_event_id`, you can ensure that the page containing the
135+
given event will be loaded. This will ignore the `page` argument.
133136
"""
134137
hits = self.service.search(
135138
identity=g.identity,
136139
request_id=resource_requestctx.view_args["request_id"],
140+
include_event_id=resource_requestctx.args.get("include_event_id"),
137141
params=resource_requestctx.args,
138142
search_preference=search_preference(),
139143
expand=resource_requestctx.args.get("expand", False),

invenio_requests/services/events/service.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010

1111
"""RequestEvents Service."""
1212

13+
import sqlalchemy.exc
1314
from flask_principal import AnonymousIdentity
1415
from invenio_i18n import _
1516
from invenio_notifications.services.uow import NotificationOp
1617
from invenio_records_resources.services import RecordService, ServiceSchemaWrapper
1718
from invenio_records_resources.services.base.links import LinksTemplate
19+
from invenio_records_resources.services.records.params import PaginationParam
1820
from invenio_records_resources.services.uow import (
1921
RecordCommitOp,
2022
RecordIndexOp,
@@ -236,38 +238,68 @@ def delete(self, identity, id_, revision_id=None, uow=None):
236238
return True
237239

238240
def search(
239-
self, identity, request_id, params=None, search_preference=None, **kwargs
241+
self,
242+
identity,
243+
request_id,
244+
include_event_id=None,
245+
params=None,
246+
search_preference=None,
247+
**kwargs
240248
):
241249
"""Search for events for a given request matching the querystring."""
242250
params = params or {}
243251
params.setdefault("sort", "oldest")
244252

245-
expand = kwargs.pop("expand", False)
246-
247253
# Permissions - guarded by the request's can_read.
248254
request = self._get_request(request_id)
249255
self.require_permission(identity, "read", request=request)
250256

251-
# Prepare and execute the search
257+
request_filter = dsl.Q("term", request_id=str(request.id))
258+
259+
# Prepare the search
252260
search = self._search(
253261
"search",
254262
identity,
255263
params,
256264
search_preference,
257265
permission_action="unused",
258-
extra_filter=dsl.Q("term", request_id=str(request.id)),
266+
extra_filter=request_filter,
267+
versioning=False,
259268
**kwargs,
260269
)
261-
search_result = search.execute()
262270

271+
if include_event_id is not None:
272+
# If a specific event ID is requested, we need to work out the corresponding page number.
273+
try:
274+
event = self._get_event(include_event_id)
275+
self.require_permission(identity, "read", request=request, event=event)
276+
277+
num_older_than_event = search.filter(
278+
"range", created={"lt": event.created}
279+
).count()
280+
page = num_older_than_event // params["size"] + 1
281+
# Re run the pagination param interpreter to update the search with the new page number
282+
params.update(page=page)
283+
search = PaginationParam(self.config.search).apply(
284+
identity, search, params
285+
)
286+
except sqlalchemy.exc.NoResultFound:
287+
# Silently fail if the ID doesn't correspond to a valid event.
288+
# In this case we just default to the page the user originally requested (or page 1).
289+
pass
290+
291+
# We deactivated versioning before (it doesn't apply for count queries) so we need to re-enable it.
292+
search_result = search.params(version=True).execute()
293+
294+
expand = kwargs.pop("expand", False)
263295
return self.result_list(
264296
self,
265297
identity,
266298
search_result,
267299
params,
268300
links_tpl=LinksTemplate(
269301
self.config.links_search,
270-
context={"request_id": str(request.id), "args": params},
302+
context={"request_id": str(request_id), "args": params},
271303
),
272304
links_item_tpl=self.links_item_tpl,
273305
expandable_fields=self.expandable_fields,

0 commit comments

Comments
 (0)