Skip to content

Commit 389b754

Browse files
zzacharoyashlamba
andcommitted
feat: implement comprehensive reviewers functionality for requests
This commit introduces a complete reviewers system for Invenio Requests, enabling assignment and management of reviewers throughout the request lifecycle. **Frontend Changes:** - Add RequestReviewers React component with search and selection functionality - Implement CollapsedHeader, ReviewerSearch, and SelectedReviewersList components - Add reviewer management UI to request details page - Integrate reviewers timeline events and API endpoints **Backend Changes:** - Add RequestReviewersComponent for service layer reviewer management - Implement ReviewersUpdatedType custom event for timeline tracking - Add reviewer-specific permissions and access control generators - Update request schema and mappings to support reviewers field - Add multi-entity reference support for reviewer entities **Database & Schema:** - Update OpenSearch mappings (v1, v2, v7) with dynamic reviewer templates - Extend request JSON schema with reviewers definitions - Add reviewer entity reference system fields **Configuration & Permissions:** - Make reviewers configurable through request types - Add reviewer-specific permission checks and generators - Update service configurations to handle reviewer operations **API & Services:** - Extend request service with reviewer update capabilities - Add reviewer-specific API endpoints and parameters - Implement proper validation and error handling for reviewer operations **Testing & Quality:** - Update test configurations and fixtures - Fix linting issues and code style violations - Add comprehensive test coverage for reviewer functionality This implementation provides a complete reviewers workflow including assignment, removal, permission management, and timeline tracking while maintaining backward compatibility with existing request functionality. Co-authored-by: Yash Lamba <[email protected]>
1 parent cd209a7 commit 389b754

File tree

36 files changed

+797
-49
lines changed

36 files changed

+797
-49
lines changed

invenio_requests/assets/semantic-ui/js/invenio_requests/InvenioRequestsApp.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ export class InvenioRequestsApp extends Component {
3939
}
4040

4141
render() {
42-
const { overriddenCmps, userAvatar, permissions } = this.props;
42+
const { overriddenCmps, userAvatar, permissions, config } = this.props;
4343

4444
return (
4545
<OverridableContext.Provider value={overriddenCmps}>
4646
<Provider store={this.store}>
47-
<Request userAvatar={userAvatar} permissions={permissions} />
47+
<Request userAvatar={userAvatar} permissions={permissions} config={config} />
4848
</Provider>
4949
</OverridableContext.Provider>
5050
);
@@ -59,11 +59,15 @@ InvenioRequestsApp.propTypes = {
5959
userAvatar: PropTypes.string.isRequired,
6060
defaultQueryParams: PropTypes.object,
6161
permissions: PropTypes.object.isRequired,
62+
config: PropTypes.object,
6263
};
6364

6465
InvenioRequestsApp.defaultProps = {
6566
overriddenCmps: {},
6667
requestsApi: null,
6768
requestEventsApi: null,
6869
defaultQueryParams: { size: 15 },
70+
config: {
71+
allowGroupReviewers: false,
72+
},
6973
};

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export class InvenioRequestsAPI {
7373
});
7474
};
7575

76+
addReviewer = async (reviewers) => {
77+
return await http.put(this.#urls.self, {
78+
reviewers: reviewers.map((r) => {
79+
return "user" in r ? { user: r.id } : { group: r.id };
80+
}),
81+
});
82+
};
83+
7684
performAction = async (action, commentContent = null) => {
7785
let payload = {};
7886
if (!_isEmpty(commentContent)) {

invenio_requests/assets/semantic-ui/js/invenio_requests/request/Request.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export class Request extends Component {
3232
request={request}
3333
userAvatar={userAvatar}
3434
permissions={permissions}
35+
config={this.props.config}
3536
/>
3637
</Loader>
3738
</Overridable>
@@ -45,6 +46,7 @@ Request.propTypes = {
4546
updateRequestAfterAction: PropTypes.func.isRequired,
4647
userAvatar: PropTypes.string,
4748
permissions: PropTypes.object.isRequired,
49+
config: PropTypes.object.isRequired,
4850
};
4951

5052
Request.defaultProps = {

invenio_requests/assets/semantic-ui/js/invenio_requests/request/RequestDetails.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Timeline } from "../timeline";
1414

1515
class RequestDetails extends Component {
1616
render() {
17-
const { request, userAvatar, permissions } = this.props;
17+
const { request, userAvatar, permissions, config } = this.props;
1818
return (
1919
<Overridable id="InvenioRequests.RequestDetails.layout" {...this.props}>
2020
<Grid stackable reversed="mobile">
@@ -26,7 +26,11 @@ class RequestDetails extends Component {
2626
/>
2727
</Grid.Column>
2828
<Grid.Column mobile={16} tablet={4} computer={3}>
29-
<RequestMetadata request={request} />
29+
<RequestMetadata
30+
request={request}
31+
permissions={permissions}
32+
config={config}
33+
/>
3034
</Grid.Column>
3135
</Grid>
3236
</Overridable>
@@ -38,6 +42,7 @@ RequestDetails.propTypes = {
3842
request: PropTypes.object.isRequired,
3943
userAvatar: PropTypes.string,
4044
permissions: PropTypes.object.isRequired,
45+
config: PropTypes.object.isRequired,
4146
};
4247

4348
RequestDetails.defaultProps = {

invenio_requests/assets/semantic-ui/js/invenio_requests/request/RequestMetadata.js

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import PropTypes from "prop-types";
1111
import React, { Component } from "react";
1212
import { Image } from "react-invenio-forms";
1313
import Overridable from "react-overridable";
14-
import { Divider, Header, Icon, Message } from "semantic-ui-react";
14+
import { Divider, Header, Icon, Message, Button } from "semantic-ui-react";
1515
import { toRelativeTime } from "react-invenio-forms";
1616
import RequestStatus from "./RequestStatus";
1717
import RequestTypeLabel from "./RequestTypeLabel";
18+
import { RequestReviewers } from "./reviewers/RequestReviewers";
1819

1920
const User = ({ user }) => (
2021
<div className="flex">
@@ -84,10 +85,8 @@ ExternalEmail.propTypes = {
8485

8586
const Group = ({ group }) => (
8687
<div className="flex">
87-
<Icon name="group" className="mr-5" />
88-
<span>
89-
{i18next.t("Group")}: {group?.name}
90-
</span>
88+
<Icon name="group" className="mr-10" />
89+
<span>{group?.name}</span>
9190
</div>
9291
);
9392

@@ -97,7 +96,7 @@ Group.propTypes = {
9796
}).isRequired,
9897
};
9998

100-
const EntityDetails = ({ userData, details }) => {
99+
export const EntityDetails = ({ userData, details }) => {
101100
const isUser = "user" in userData;
102101
const isCommunity = "community" in userData;
103102
const isExternalEmail = "email" in userData;
@@ -139,7 +138,7 @@ EntityDetails.propTypes = {
139138
]).isRequired,
140139
};
141140

142-
const DeletedResource = ({ details }) => (
141+
export const DeletedResource = ({ details }) => (
143142
<Message negative>{details.metadata.title}</Message>
144143
);
145144

@@ -158,9 +157,25 @@ class RequestMetadata extends Component {
158157
const { request } = this.props;
159158
const expandedCreatedBy = request.expanded?.created_by;
160159
const expandedReceiver = request.expanded?.receiver;
160+
161+
const enableReviewers = this.props.config.enableReviewers;
162+
const allowGroupReviewers = this.props.config.allowGroupReviewers;
163+
const maxReviewers = this.props.config.maxReviewers;
164+
161165
return (
162166
<Overridable id="InvenioRequest.RequestMetadata.Layout" request={request}>
163167
<>
168+
{enableReviewers && (
169+
<>
170+
<RequestReviewers
171+
request={request}
172+
permissions={this.props.permissions}
173+
allowGroupReviewers={allowGroupReviewers}
174+
maxReviewers={maxReviewers}
175+
/>
176+
<Divider />
177+
</>
178+
)}
164179
{expandedCreatedBy !== undefined && (
165180
<>
166181
<Header as="h3" size="tiny">
@@ -235,6 +250,8 @@ class RequestMetadata extends Component {
235250

236251
RequestMetadata.propTypes = {
237252
request: PropTypes.object.isRequired,
253+
config: PropTypes.object.isRequired,
254+
permissions: PropTypes.object.isRequired,
238255
};
239256

240257
export default Overridable.component(
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* This file is part of Invenio.
3+
* Copyright (C) 2025 CERN.
4+
*
5+
* Invenio is free software; you can redistribute it and/or modify it
6+
* under the terms of the MIT License; see LICENSE file for more details.
7+
*/
8+
9+
import React, { useState } from "react";
10+
import PropTypes from "prop-types";
11+
import { HeaderSubheader, Grid, List, Image, Segment } from "semantic-ui-react";
12+
import { UsersApi } from "@js/invenio_communities/api/UsersApi";
13+
import { GroupsApi } from "@js/invenio_communities/api/GroupsApi";
14+
import {
15+
InvenioRequestsAPI,
16+
RequestLinksExtractor,
17+
} from "@js/invenio_requests/api/InvenioRequestApi";
18+
import { i18next } from "@translations/invenio_requests/i18next";
19+
import RequestsFeed from "../../components/RequestsFeed";
20+
import { EntityDetails, DeletedResource } from "../RequestMetadata";
21+
import { CollapsedHeader } from "./components/CollapsedHeader";
22+
import { ReviewerSearch } from "./components/ReviewerSearch";
23+
import { SelectedReviewersList } from "./components/SelectedReviewersList";
24+
25+
const isResourceDeleted = (details) => details.is_ghost === true;
26+
27+
export const RequestReviewers = ({
28+
request,
29+
permissions,
30+
allowGroupReviewers,
31+
maxReviewers,
32+
}) => {
33+
const [isMenuOpen, setIsMenuOpen] = useState(false);
34+
const [searchType, setSearchType] = useState("user");
35+
const [searchQuery, setSearchQuery] = useState("");
36+
const [results, setResults] = useState([]);
37+
38+
const reviewers = request.expanded?.reviewers || [];
39+
40+
const initialReviewers = reviewers.map((r, index) => {
41+
return "user" in request.reviewers[index]
42+
? { ...r, user: request.reviewers[index].user }
43+
: { ...r, group: request.reviewers[index].group };
44+
});
45+
46+
const [selectedReviewers, setSelectedReviewers] = useState(initialReviewers);
47+
const requestApi = new InvenioRequestsAPI(new RequestLinksExtractor(request));
48+
49+
const handleSearchChange = async (e, { value }) => {
50+
setSearchQuery(value);
51+
if (value.length > 1) {
52+
try {
53+
let suggestions;
54+
if (searchType === "user") {
55+
const usersClient = new UsersApi();
56+
suggestions = await usersClient.suggestUsers(value);
57+
} else {
58+
const groupsClient = new GroupsApi();
59+
suggestions = await groupsClient.getGroups(value);
60+
}
61+
setResults(suggestions.data.hits.hits);
62+
} catch (error) {
63+
console.error(`Error fetching ${searchType} suggestions:`, error);
64+
setResults([]);
65+
}
66+
} else {
67+
setResults([]);
68+
}
69+
};
70+
71+
const handleResultSelect = async (e, { result }) => {
72+
if (!selectedReviewers.find((r) => r.id === result.id)) {
73+
const newReviewers = [
74+
...selectedReviewers,
75+
{ ...result, [searchType]: result.id },
76+
];
77+
setSelectedReviewers(newReviewers);
78+
const _ = await requestApi.addReviewer(newReviewers);
79+
}
80+
setSearchQuery("");
81+
setResults([]);
82+
};
83+
84+
const removeReviewer = async (userId) => {
85+
const newReviewers = selectedReviewers.filter((r) => r.id !== userId);
86+
setSelectedReviewers(newReviewers);
87+
const _ = await requestApi.addReviewer(newReviewers);
88+
setSelectedReviewers(newReviewers);
89+
};
90+
91+
// A helper to render a search result item.
92+
const renderResult = (item) => (
93+
<List.Item key={item.id}>
94+
<RequestsFeed.Avatar src={item.links?.avatar} as={Image} circular size="tiny" />
95+
<List.Content>{item.profile?.full_name || item.name}</List.Content>
96+
</List.Item>
97+
);
98+
99+
return (
100+
<>
101+
<CollapsedHeader
102+
canUpdateReviewers={permissions.can_action_accept}
103+
onOpen={() => setIsMenuOpen(!isMenuOpen)}
104+
label={i18next.t("Reviewers")}
105+
/>
106+
{!isMenuOpen ? (
107+
<Grid className="mt-0 mb-5">
108+
{selectedReviewers.length > 0 ? (
109+
selectedReviewers.map((reviewer) => (
110+
<>
111+
<Grid.Column width={14} className="pb-0">
112+
<React.Fragment key={reviewer.id}>
113+
{isResourceDeleted(reviewer) ? (
114+
<DeletedResource details={reviewer} />
115+
) : (
116+
<>
117+
<EntityDetails userData={reviewer} details={reviewer} />
118+
</>
119+
)}
120+
</React.Fragment>
121+
</Grid.Column>
122+
</>
123+
))
124+
) : (
125+
<Grid.Column width={12} className="pb-0 pl-20">
126+
<HeaderSubheader>{i18next.t("No reviewers selected")}</HeaderSubheader>
127+
</Grid.Column>
128+
)}
129+
</Grid>
130+
) : (
131+
<Segment>
132+
<ReviewerSearch
133+
searchType={searchType}
134+
onFilterChange={setSearchType}
135+
searchQuery={searchQuery}
136+
results={results}
137+
onSearchChange={handleSearchChange}
138+
onResultSelect={handleResultSelect}
139+
renderResult={renderResult}
140+
i18next={i18next}
141+
allowGroupReviewers={allowGroupReviewers}
142+
/>
143+
144+
<SelectedReviewersList
145+
selectedReviewers={selectedReviewers}
146+
removeReviewer={removeReviewer}
147+
i18next={i18next}
148+
maxReviewers={maxReviewers}
149+
/>
150+
</Segment>
151+
)}
152+
</>
153+
);
154+
};
155+
156+
RequestReviewers.propTypes = {
157+
request: PropTypes.object.isRequired,
158+
permissions: PropTypes.shape({
159+
can_action_accept: PropTypes.bool.isRequired,
160+
}).isRequired,
161+
allowGroupReviewers: PropTypes.bool.isRequired,
162+
maxReviewers: PropTypes.number.isRequired,
163+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* This file is part of Invenio.
3+
* Copyright (C) 2025 CERN.
4+
*
5+
* Invenio is free software; you can redistribute it and/or modify it
6+
* under the terms of the MIT License; see LICENSE file for more details.
7+
*/
8+
9+
import PropTypes from "prop-types";
10+
import React from "react";
11+
import { Grid, Header, Icon } from "semantic-ui-react";
12+
13+
// Renders the header when the menu is collapsed.
14+
export const CollapsedHeader = ({ canUpdateReviewers, onOpen, label }) => {
15+
if (!canUpdateReviewers) {
16+
return (
17+
<Header as="h3" size="tiny" className="mb-0">
18+
{label}
19+
</Header>
20+
);
21+
}
22+
return (
23+
<Grid onClick={onOpen} className="pb-0 mr-0">
24+
<Grid.Column width={12} floated="left">
25+
<Header as="h3" size="tiny" className="m-0">
26+
{label}
27+
</Header>
28+
</Grid.Column>
29+
<Grid.Column floated="right" className="mt-2 pr-20">
30+
<Icon name="setting" className="m-0 link" />
31+
</Grid.Column>
32+
</Grid>
33+
);
34+
};
35+
36+
CollapsedHeader.propTypes = {
37+
canUpdateReviewers: PropTypes.bool.isRequired,
38+
onOpen: PropTypes.func,
39+
label: PropTypes.string.isRequired,
40+
};

0 commit comments

Comments
 (0)