Skip to content

Commit a5e0c0c

Browse files
committed
js+service: [inveniosoftware#855] 4) integrate request flow with frontend [+]
This concludes the 2nd flow of the membership request feature. Remaining flows are - 'waiting for decision' flow - 'making a decision' flow This PR needs to be complemented by: - one in invenio-requests (done) - one in invenio-rdm-records (to do)
1 parent bbf2ebc commit a5e0c0c

File tree

13 files changed

+559
-335
lines changed

13 files changed

+559
-335
lines changed

invenio_communities/assets/semantic-ui/js/invenio_communities/api/CommunityLinksExtractor.js

+8
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,12 @@ export class CommunityLinksExtractor {
3535
}
3636
return this.#urls.invitations;
3737
}
38+
39+
url(key) {
40+
const urlOfKey = this.#urls[key];
41+
if (!urlOfKey) {
42+
throw TypeError(`"${key}" link missing from resource.`);
43+
}
44+
return urlOfKey;
45+
}
3846
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// This file is part of Invenio-communities
2+
// Copyright (C) 2024 Northwestern University.
3+
//
4+
// Invenio-communities 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 class RequestLinksExtractor {
8+
#urls;
9+
10+
constructor(request) {
11+
if (!request?.links) {
12+
throw TypeError("Request resource links are undefined");
13+
}
14+
this.#urls = request.links;
15+
}
16+
17+
url(key) {
18+
const urlOfKey = this.#urls[key];
19+
if (!urlOfKey) {
20+
throw TypeError(`"${key}" link missing from resource.`);
21+
}
22+
return urlOfKey;
23+
}
24+
25+
get userDiscussionUrl() {
26+
const result = this.url("self_html");
27+
return result.replace("/requests/", "/me/requests/");
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// This file is part of Invenio-communities
2+
// Copyright (C) 2022 CERN.
3+
// Copyright (C) 2024 Northwestern University.
4+
//
5+
// Invenio-communities 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+
import { CommunityLinksExtractor } from "../CommunityLinksExtractor";
9+
import { http } from "react-invenio-forms";
10+
11+
/**
12+
* API Client for community membership requests.
13+
*
14+
* It mostly uses the API links passed to it from initial community.
15+
*
16+
*/
17+
export class CommunityMembershipRequestsApi {
18+
constructor(community) {
19+
this.community = community;
20+
this.linksExtractor = new CommunityLinksExtractor(community);
21+
}
22+
23+
requestMembership = async (payload) => {
24+
return await http.post(this.linksExtractor.url("membership_requests"), payload);
25+
};
26+
}

invenio_communities/assets/semantic-ui/js/invenio_communities/community/header/RequestMembershipButton.js

+59-15
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,49 @@ import { Formik } from "formik";
1212
import PropTypes from "prop-types";
1313
import React, { useState } from "react";
1414
import { TextAreaField } from "react-invenio-forms";
15-
import { Button, Form, Modal } from "semantic-ui-react";
15+
import { Button, Form, Grid, Message, Modal } from "semantic-ui-react";
16+
17+
import { CommunityMembershipRequestsApi } from "../../api/membershipRequests/api";
18+
import { communityErrorSerializer } from "../../api/serializers";
19+
import { RequestLinksExtractor } from "../../api/RequestLinksExtractor";
1620

1721
export function RequestMembershipModal(props) {
18-
const { isOpen, onClose } = props;
22+
const [errorMsg, setErrorMsg] = useState("");
23+
24+
const { community, isOpen, onClose } = props;
1925

2026
const onSubmit = async (values, { setSubmitting, setFieldError }) => {
21-
// TODO: implement me
22-
console.log("RequestMembershipModal.onSubmit(args) called");
23-
console.log("TODO: implement me", arguments);
24-
};
27+
/**Submit callback called from Formik. */
28+
setSubmitting(true);
29+
30+
const client = new CommunityMembershipRequestsApi(community);
31+
32+
try {
33+
const response = await client.requestMembership(values);
34+
const linksExtractor = new RequestLinksExtractor(response.data);
35+
window.location.href = linksExtractor.userDiscussionUrl;
36+
} catch (error) {
37+
setSubmitting(false);
38+
39+
console.log("Error");
40+
console.dir(error);
2541

26-
let confirmed = true;
42+
const { errors, message } = communityErrorSerializer(error);
43+
44+
if (message) {
45+
setErrorMsg(message);
46+
}
47+
48+
if (errors) {
49+
errors.forEach(({ field, messages }) => setFieldError(field, messages[0]));
50+
}
51+
}
52+
};
2753

2854
return (
2955
<Formik
3056
initialValues={{
31-
requestMembershipComment: "",
57+
message: "",
3258
}}
3359
onSubmit={onSubmit}
3460
>
@@ -42,9 +68,17 @@ export function RequestMembershipModal(props) {
4268
>
4369
<Modal.Header>{i18next.t("Request Membership")}</Modal.Header>
4470
<Modal.Content>
71+
<Message hidden={errorMsg === ""} negative className="flashed">
72+
<Grid container>
73+
<Grid.Column mobile={16} tablet={12} computer={8} textAlign="left">
74+
<strong>{errorMsg}</strong>
75+
</Grid.Column>
76+
</Grid>
77+
</Message>
78+
4579
<Form>
4680
<TextAreaField
47-
fieldPath="requestMembershipComment"
81+
fieldPath="message"
4882
label={i18next.t("Message to managers (optional)")}
4983
/>
5084
</Form>
@@ -54,12 +88,12 @@ export function RequestMembershipModal(props) {
5488
{i18next.t("Cancel")}
5589
</Button>
5690
<Button
57-
onClick={(event) => {
58-
// TODO: Implement me
59-
console.log("RequestMembershipModal button clicked.");
60-
}}
61-
positive={confirmed}
91+
disabled={isSubmitting}
92+
loading={isSubmitting}
93+
onClick={handleSubmit}
94+
positive
6295
primary
96+
type="button"
6397
>
6498
{i18next.t("Request Membership")}
6599
</Button>
@@ -73,10 +107,12 @@ export function RequestMembershipModal(props) {
73107
RequestMembershipModal.propTypes = {
74108
isOpen: PropTypes.bool.isRequired,
75109
onClose: PropTypes.func.isRequired,
110+
community: PropTypes.object.isRequired,
76111
};
77112

78113
export function RequestMembershipButton(props) {
79114
const [isModalOpen, setModalOpen] = useState(false);
115+
const { community } = props;
80116

81117
const handleClick = () => {
82118
setModalOpen(true);
@@ -97,8 +133,16 @@ export function RequestMembershipButton(props) {
97133
content={i18next.t("Request Membership")}
98134
/>
99135
{isModalOpen && (
100-
<RequestMembershipModal isOpen={isModalOpen} onClose={handleClose} />
136+
<RequestMembershipModal
137+
isOpen={isModalOpen}
138+
onClose={handleClose}
139+
community={community}
140+
/>
101141
)}
102142
</>
103143
);
104144
}
145+
146+
RequestMembershipButton.propTypes = {
147+
community: PropTypes.object.isRequired,
148+
};

invenio_communities/communities/services/config.py

+3
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ class CommunityServiceConfig(RecordServiceConfig, ConfiguratorMixin):
114114
"invitations": CommunityLink("{+api}/communities/{id}/invitations"),
115115
"requests": CommunityLink("{+api}/communities/{id}/requests"),
116116
"records": CommunityLink("{+api}/communities/{id}/records"),
117+
"membership_requests": CommunityLink(
118+
"{+api}/communities/{id}/membership-requests"
119+
),
117120
}
118121

119122
action_link = CommunityLink(

invenio_communities/members/services/request.py

+21
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ def service():
2929
return current_communities.service.members
3030

3131

32+
#
33+
# CommunityInvitation: actions and request type
34+
#
35+
36+
3237
#
3338
# Actions
3439
#
@@ -126,6 +131,21 @@ class CommunityInvitation(RequestType):
126131
}
127132

128133

134+
#
135+
# MembershipRequestRequestType: actions and request type
136+
#
137+
138+
139+
class CancelMembershipRequestAction(actions.CancelAction):
140+
"""Cancel membership request action."""
141+
142+
def execute(self, identity, uow):
143+
"""Execute action."""
144+
service().close_membership_request(system_identity, self.request.id, uow=uow)
145+
# TODO: Investigate notifications
146+
super().execute(identity, uow)
147+
148+
129149
class MembershipRequestRequestType(RequestType):
130150
"""Request type for membership requests."""
131151

@@ -135,6 +155,7 @@ class MembershipRequestRequestType(RequestType):
135155
create_action = "create"
136156
available_actions = {
137157
"create": actions.CreateAndSubmitAction,
158+
"cancel": CancelMembershipRequestAction,
138159
}
139160

140161
creator_can_be_none = False

invenio_communities/members/services/service.py

+14-4
Original file line numberDiff line numberDiff line change
@@ -843,7 +843,17 @@ def accept_membership_request(self, identity, request_id, uow=None):
843843
pass
844844

845845
@unit_of_work()
846-
def decline_membership_request(self, identity, request_id, uow=None):
847-
"""Decline membership request."""
848-
# TODO: Implement me
849-
pass
846+
def close_membership_request(self, identity, request_id, uow=None):
847+
"""Close membership request.
848+
849+
Used for cancelling, declining, or expiring a membership request.
850+
851+
For now we just delete the "fake" member that was created in
852+
request_membership. TODO: explore alternatives/ramifications at a
853+
later point.
854+
"""
855+
# Permissions are checked on the request action
856+
assert identity == system_identity
857+
member = self.record_cls.get_member_by_request(request_id)
858+
assert member.active is False
859+
uow.register(RecordDeleteOp(member, indexer=self.indexer, force=True))

setup.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ python_requires = >=3.8
2828
zip_safe = False
2929
install_requires =
3030
invenio-oaiserver>=2.2.0,<3.0.0
31-
invenio-requests>=4.0.0,<5.0.0
31+
invenio-requests>=4.2.0,<5.0.0
3232
invenio-search-ui>=2.4.0,<3.0.0
3333
invenio-vocabularies>=4.0.0,<5.0.0
3434
invenio-administration>=2.0.0,<3.0.0

tests/conftest.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ def create_user(UserFixture, app, db):
371371
is essential for many tests.
372372
"""
373373

374-
def _create_user(data):
374+
def _create_user(data=None):
375375
"""Create user."""
376376
default_data = dict(
377377
@@ -391,6 +391,7 @@ def _create_user(data):
391391
active=True,
392392
confirmed=True,
393393
)
394+
data = data or {}
394395
actual_data = dict(default_data, **data)
395396
u = UserFixture(**actual_data)
396397
u.create(app, db)

tests/members/conftest.py

+15
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from invenio_access.permissions import system_identity
1717
from invenio_requests.records.api import Request
1818
from invenio_search import current_search
19+
from invenio_users_resources.proxies import current_users_service
1920

2021
from invenio_communities.members.records.api import ArchivedInvitation, Member
2122

@@ -93,3 +94,17 @@ def invite_request_id(requests_service, invite_user):
9394
type="community-invitation",
9495
).to_dict()
9596
return res["hits"]["hits"][0]["id"]
97+
98+
99+
@pytest.fixture(scope="function")
100+
def membership_request(member_service, community, create_user, db, search_clear):
101+
"""A membership request."""
102+
user = create_user()
103+
data = {
104+
"message": "Can I join the club?",
105+
}
106+
return member_service.request_membership(
107+
user.identity,
108+
community._record.id,
109+
data,
110+
)

0 commit comments

Comments
 (0)