From 6cabaccd7dede6199d85f75dca1a640369ed3017 Mon Sep 17 00:00:00 2001 From: Jose Buitron Date: Tue, 5 Jul 2022 15:14:33 -0500 Subject: [PATCH] Feat: Complete Restrict Membership (Feature PR) (#430) * chore: Added feature branch GH actions support * feat: Restrict Membership - membership button update (#431) * feat: Restrict membership: update/add group membership type (#433) * feat: Restrict membership - group directory update (#435) * feat: Restrict membership - group profile (#434) * feat: Restrict membership - Members page (#432) * feat: Restrict membership - manager notification (#436) * feat: Restrict Membership - Show member pending groups (#437) * fix: Shared data section, info dialog fixes (#438) * fix: Added URLs, fixed column size (#440) * fix: Removed not used dependency --- .github/workflows/build.yml | 4 +- .github/workflows/check-commits.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 +- .github/workflows/localization.yml | 4 +- src/common/components/List.js | 17 + src/group/components/GroupForm.js | 77 ++- src/group/components/GroupForm.test.js | 54 ++- src/group/components/GroupList.js | 25 +- src/group/components/GroupList.test.js | 5 - src/group/components/GroupView.js | 13 + src/group/components/GroupsHomeCard.js | 83 +++- src/group/groupContext.js | 2 + src/group/groupFragments.js | 13 +- src/group/groupService.js | 7 +- src/group/groupSlice.js | 20 +- src/group/groupUtils.js | 4 +- .../components/GroupMemberRequestCancel.js | 29 ++ .../membership/components/GroupMembers.js | 21 +- .../components/GroupMembers.test.js | 452 ++++++++++++++++++ .../membership/components/GroupMembersList.js | 205 ++++++-- .../components/GroupMembershipCard.js | 81 +++- .../components/GroupMembershipCard.test.js | 119 +++++ .../components/GroupMembershipInfo.js | 59 +++ .../GroupMembershipJoinLeaveButton.js | 52 +- .../GroupMembershipPendingWarning.js | 36 ++ .../components/groupMembershipConstants.js | 5 + src/home/components/Home.test.js | 49 +- src/home/homeService.js | 9 +- src/landscape/components/LandscapeList.js | 3 +- .../components/LandscapeList.test.js | 4 +- .../components/LandscapeView.test.js | 1 + .../components/LandscapeMembers.test.js | 50 +- src/localization/locales/en-US.json | 37 +- src/localization/locales/es-ES.json | 35 +- src/permissions/rules.js | 54 ++- src/react-hoc.js | 15 +- src/sharedData/components/SharedDataCard.js | 21 +- 37 files changed, 1471 insertions(+), 200 deletions(-) create mode 100644 src/common/components/List.js create mode 100644 src/group/membership/components/GroupMemberRequestCancel.js create mode 100644 src/group/membership/components/GroupMembers.test.js create mode 100644 src/group/membership/components/GroupMembershipInfo.js create mode 100644 src/group/membership/components/GroupMembershipPendingWarning.js create mode 100644 src/group/membership/components/groupMembershipConstants.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b6f69eef..362c97403 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,11 +4,11 @@ on: push: branches: - main - - feature/shared-files + - feature/restrict-membership pull_request: branches: - main - - feature/shared-files + - feature/restrict-membership jobs: lint: diff --git a/.github/workflows/check-commits.yml b/.github/workflows/check-commits.yml index 77ef2f1d6..1497d1663 100644 --- a/.github/workflows/check-commits.yml +++ b/.github/workflows/check-commits.yml @@ -4,7 +4,7 @@ on: pull_request: branches: - main - - feature/shared-files + - feature/restrict-membership types: - opened - edited diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8b3dbc2fb..477c0f743 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ main, feature/shared-files ] + branches: [ main, feature/restrict-membership ] pull_request: # The branches below must be a subset of the branches above - branches: [ main, feature/shared-files ] + branches: [ main, feature/restrict-membership ] schedule: - cron: '34 6 * * 4' diff --git a/.github/workflows/localization.yml b/.github/workflows/localization.yml index e5abf568c..d56b4f459 100644 --- a/.github/workflows/localization.yml +++ b/.github/workflows/localization.yml @@ -4,11 +4,11 @@ on: push: branches: - main - - feature/shared-files + - feature/restrict-membership pull_request: branches: - main - - feature/shared-files + - feature/restrict-membership jobs: missing-keys: diff --git a/src/common/components/List.js b/src/common/components/List.js new file mode 100644 index 000000000..4d3dcbbac --- /dev/null +++ b/src/common/components/List.js @@ -0,0 +1,17 @@ +import React from 'react'; + +import { List as BaseList, Divider, Paper, Stack } from '@mui/material'; + +import { withProps } from 'react-hoc'; + +const List = withProps(BaseList, { + component: withProps(Stack, { + divider: , + component: withProps(Paper, { + variant: 'outlined', + component: 'ul', + }), + }), +}); + +export default List; diff --git a/src/group/components/GroupForm.js b/src/group/components/GroupForm.js index da91eeb0e..75381bf0b 100644 --- a/src/group/components/GroupForm.js +++ b/src/group/components/GroupForm.js @@ -6,7 +6,13 @@ import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useParams } from 'react-router-dom'; import * as yup from 'yup'; -import { Typography } from '@mui/material'; +import { + FormControlLabel, + Radio, + RadioGroup, + Stack, + Typography, +} from '@mui/material'; import { useDocumentTitle } from 'common/document'; import { transformURL } from 'common/utils'; @@ -21,6 +27,10 @@ import { saveGroup, setFormNewValues, } from 'group/groupSlice'; +import { + MEMBERSHIP_CLOSED, + MEMBERSHIP_OPEN, +} from 'group/membership/components/groupMembershipConstants'; import { MAX_DESCRIPTION_LENGTH } from 'config'; @@ -36,6 +46,7 @@ const VALIDATION_SCHEMA = yup .required(), email: yup.string().trim().email(), website: yup.string().trim().ensure().transform(transformURL).url(), + membershipType: yup.string(), }) .required(); @@ -67,8 +78,70 @@ const FIELDS = [ placeholder: 'group.form_website_placeholder', type: 'url', }, + { + name: 'membershipType', + label: 'group.form_membershipType_label', + defaultValue: MEMBERSHIP_OPEN, + props: { + renderInput: ({ field }) => ( + + ), + }, + }, ]; +const MembershipRadioButton = props => { + const { value, label, description, sx } = props; + return ( + + } + label={ + + {label} + {description} + + } + sx={{ mb: 2, alignItems: 'flex-start', ...sx }} + /> + ); +}; +const MembershipRadioButtons = props => { + const { t } = useTranslation(); + const { value, onChange } = props; + + const handleChange = event => { + onChange(event.target.value); + }; + + return ( + + + + + ); +}; + const GroupForm = () => { const dispatch = useDispatch(); const { t } = useTranslation(); @@ -151,7 +224,7 @@ const GroupForm = () => { aria-labelledby="group-form-page-title" prefix="group" fields={FIELDS} - values={group} + values={isNew ? { membershipType: MEMBERSHIP_OPEN } : group} validationSchema={VALIDATION_SCHEMA} onSave={onSave} saveLabel={isNew ? 'group.form_create_label' : 'group.form_save_label'} diff --git a/src/group/components/GroupForm.test.js b/src/group/components/GroupForm.test.js index 4d3265859..42d2c2e57 100644 --- a/src/group/components/GroupForm.test.js +++ b/src/group/components/GroupForm.test.js @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from 'tests/utils'; +import { fireEvent, render, screen, within } from 'tests/utils'; import React from 'react'; @@ -35,12 +35,19 @@ const setup = async () => { }); const email = screen.getByRole('textbox', { name: 'Email address' }); const website = screen.getByRole('textbox', { name: 'Website' }); + const membershipType = screen.getByRole('radiogroup', { + name: 'Membership Type', + }); + const membershipTypeOpen = within(membershipType).getByLabelText('Open'); + const membershipTypeClose = within(membershipType).getByLabelText('Closed'); return { inputs: { name, description, email, website, + membershipTypeOpen, + membershipTypeClose, }, }; }; @@ -216,6 +223,7 @@ test('GroupForm: website accepts address without protocol', async () => { description: 'Group description', email: 'group@group.org', website: 'https://www.group.org', + membershipType: 'OPEN', }, }, ], @@ -254,6 +262,7 @@ test('GroupForm: Save form', async () => { description: 'Group description', email: 'group@group.org', website: 'https://www.group.org', + membershipType: 'OPEN', }, }, ], @@ -282,6 +291,8 @@ test('GroupForm: Save form', async () => { target: { value: 'https://www.other.org' }, }); + fireEvent.click(inputs.membershipTypeClose); + await act(async () => fireEvent.click(screen.getByText(/Save Changes/i))); expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(2); const saveCall = terrasoApi.requestGraphQL.mock.calls[1]; @@ -292,6 +303,7 @@ test('GroupForm: Save form', async () => { name: 'New name', website: 'https://www.other.org', email: 'new.email@group.org', + membershipType: 'CLOSED', }, }); }); @@ -308,6 +320,7 @@ test('GroupForm: Save form error', async () => { description: 'Group description', email: 'group@group.org', website: 'https://www.group.org', + membershipType: 'OPEN', }, }, ], @@ -356,6 +369,43 @@ test('GroupForm: Avoid fetch', async () => { screen.getByRole('progressbar', { name: 'Loading', hidden: true }) ).toThrow('Unable to find an element'); }); +test('GroupForm: Save form (add) (Default open)', async () => { + useParams.mockReturnValue({}); + terrasoApi.requestGraphQL.mockResolvedValueOnce({ + addGroup: { + group: { + name: 'New name', + description: 'New description', + website: 'https://www.other.org', + email: 'group@group.org', + }, + }, + }); + + const { inputs } = await setup(); + + fireEvent.change(inputs.name, { target: { value: 'New name' } }); + fireEvent.change(inputs.description, { + target: { value: 'New description' }, + }); + fireEvent.change(inputs.website, { + target: { value: 'https://www.other.org' }, + }); + fireEvent.change(inputs.email, { target: { value: 'other@group.org' } }); + + await act(async () => fireEvent.click(screen.getByText(/Create Group/i))); + expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(1); + const saveCall = terrasoApi.requestGraphQL.mock.calls[0]; + expect(saveCall[1]).toStrictEqual({ + input: { + description: 'New description', + name: 'New name', + website: 'https://www.other.org', + email: 'other@group.org', + membershipType: 'OPEN', + }, + }); +}); test('GroupForm: Save form (add)', async () => { useParams.mockReturnValue({}); terrasoApi.requestGraphQL.mockResolvedValueOnce({ @@ -379,6 +429,7 @@ test('GroupForm: Save form (add)', async () => { target: { value: 'https://www.other.org' }, }); fireEvent.change(inputs.email, { target: { value: 'other@group.org' } }); + fireEvent.click(inputs.membershipTypeClose); await act(async () => fireEvent.click(screen.getByText(/Create Group/i))); expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(1); @@ -389,6 +440,7 @@ test('GroupForm: Save form (add)', async () => { name: 'New name', website: 'https://www.other.org', email: 'other@group.org', + membershipType: 'CLOSED', }, }); }); diff --git a/src/group/components/GroupList.js b/src/group/components/GroupList.js index ef87e35ec..ec5e61fb6 100644 --- a/src/group/components/GroupList.js +++ b/src/group/components/GroupList.js @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; -import _ from 'lodash/fp'; import { Trans, useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { Link as RouterLink, useSearchParams } from 'react-router-dom'; @@ -17,7 +16,7 @@ import { GroupContextProvider } from 'group/groupContext'; import { fetchGroups } from 'group/groupSlice'; import GroupMemberJoin from 'group/membership/components/GroupMemberJoin'; import GroupMemberLeave from 'group/membership/components/GroupMemberLeave'; -import GroupMembershipCount from 'group/membership/components/GroupMembershipCount'; +import GroupMemberRequestCancel from 'group/membership/components/GroupMemberRequestCancel'; import GroupMembershipJoinLeaveButton from 'group/membership/components/GroupMembershipJoinLeaveButton'; import { withProps } from 'react-hoc'; @@ -39,15 +38,25 @@ const MemberLeaveButton = withProps(GroupMemberLeave, { }, }); +const MemberRequestCancelButton = withProps(GroupMemberRequestCancel, { + label: 'group.list_request_cancel_label', +}); + const MemberJoinButton = withProps(GroupMemberJoin, { label: 'group.list_join_button', }); +const MemberRequestJoinButton = withProps(GroupMemberJoin, { + label: 'group.list_request_join_button', +}); + const MembershipButton = ({ group }) => ( @@ -112,23 +121,13 @@ const GroupList = () => { ), }, - { - field: 'members', - headerName: t('group.list_column_members'), - align: 'center', - cardSize: 6, - valueGetter: ({ row: group }) => - _.getOr(0, 'membersInfo.totalCount', group), - renderCell: ({ row: group }) => ( - - ), - }, { field: 'actions', type: 'actions', headerName: t('group.list_column_actions_description'), sortable: false, align: 'center', + flex: 1, cardSize: 6, getActions: ({ row: group }) => [], }, diff --git a/src/group/components/GroupList.test.js b/src/group/components/GroupList.test.js index f1155e288..91b140f19 100644 --- a/src/group/components/GroupList.test.js +++ b/src/group/components/GroupList.test.js @@ -130,10 +130,6 @@ test('GroupList: Display list', async () => { expect( within(rows[2]).getByRole('cell', { name: 'email@email.com' }) ).toHaveAttribute('data-field', 'email'); - expect(within(rows[2]).getByRole('cell', { name: '23' })).toHaveAttribute( - 'data-field', - 'members' - ); expect(within(rows[2]).getByRole('cell', { name: 'Join' })).toHaveAttribute( 'data-field', 'actions' @@ -265,7 +261,6 @@ test('GroupList: Display list (small screen)', async () => { within(rows[1]).getByText('https://www.group.org') ).toBeInTheDocument(); expect(within(rows[1]).getByText('email@email.com')).toBeInTheDocument(); - expect(within(rows[1]).getByText('23')).toBeInTheDocument(); expect(within(rows[1]).getByText('Join')).toBeInTheDocument(); expect(within(rows[8]).getByText('Member')).toBeInTheDocument(); }); diff --git a/src/group/components/GroupView.js b/src/group/components/GroupView.js index 15dee9df6..adc0088b8 100644 --- a/src/group/components/GroupView.js +++ b/src/group/components/GroupView.js @@ -30,7 +30,9 @@ import { GroupContextProvider } from 'group/groupContext'; import { fetchGroupView, refreshGroupView } from 'group/groupSlice'; import GroupMemberJoin from 'group/membership/components/GroupMemberJoin'; import GroupMemberLeave from 'group/membership/components/GroupMemberLeave'; +import GroupMemberRequestCancel from 'group/membership/components/GroupMemberRequestCancel'; import GroupMembershipCard from 'group/membership/components/GroupMembershipCard'; +import GroupMembershipInfo from 'group/membership/components/GroupMembershipInfo'; import SharedDataCard from 'sharedData/components/SharedDataCard'; import { withProps } from 'react-hoc'; @@ -44,10 +46,18 @@ const MemberLeaveButton = withProps(GroupMemberLeave, { }, }); +const MemberRequestCancelButton = withProps(GroupMemberRequestCancel, { + label: 'group.view_request_cancel_label', +}); + const MemberJoinButton = withProps(GroupMemberJoin, { label: 'group.view_join_label', }); +const MemberRequestJoinButton = withProps(GroupMemberJoin, { + label: 'group.view_request_join_button', +}); + const GroupCard = ({ group }) => { const { t } = useTranslation(); return ( @@ -158,6 +168,8 @@ const GroupView = () => { group={group} groupSlug={group.slug} MemberJoinButton={MemberJoinButton} + MemberRequestJoinButton={MemberRequestJoinButton} + MemberRequestCancelButton={MemberRequestCancelButton} MemberLeaveButton={MemberLeaveButton} updateOwner={updateGroup} > @@ -180,6 +192,7 @@ const GroupView = () => { navigate(`/groups/${group.slug}/members`)} + InfoComponent={GroupMembershipInfo} /> diff --git a/src/group/components/GroupsHomeCard.js b/src/group/components/GroupsHomeCard.js index c1286fe61..2e870e9a0 100644 --- a/src/group/components/GroupsHomeCard.js +++ b/src/group/components/GroupsHomeCard.js @@ -2,50 +2,91 @@ import React from 'react'; import _ from 'lodash/fp'; import { useTranslation } from 'react-i18next'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { + ListItem as BaseListItem, Button, CardActions, Divider, Link, List, - ListItem, + Stack, Typography, } from '@mui/material'; +import Restricted from 'permissions/components/Restricted'; + +import GroupMembershipPendingWarning from 'group/membership/components/GroupMembershipPendingWarning'; +import { + MEMBERSHIP_STATUS_APPROVED, + MEMBERSHIP_STATUS_PENDING, +} from 'group/membership/components/groupMembershipConstants'; import HomeCard from 'home/components/HomeCard'; +import { withProps } from 'react-hoc'; + import theme from 'theme'; +const ListItem = withProps(BaseListItem, { + component: withProps(Stack, { component: 'li' }), +}); + const GroupItem = ({ group }) => { const { t } = useTranslation(); + const navigate = useNavigate(); + + const pendingCount = _.getOr(0, 'membersInfo.pendingCount', group); + const isApproved = + _.get('membersInfo.accountMembership.membershipStatus', group) === + MEMBERSHIP_STATUS_APPROVED; + + const role = isApproved + ? _.getOr('member', 'membersInfo.accountMembership.userRole', group) + : MEMBERSHIP_STATUS_PENDING; + return ( - - - {group.name} - - ({ marginLeft: theme.spacing(1) })}> - ( - {t( - `group.role_${_.getOr( - 'member', - 'accountMembership.userRole', - group - ).toLowerCase()}` + + + + {group.name} + + + ({t(`group.role_${role.toLowerCase()}`)}) + + + {!isApproved && ( + + {t('group.home_pending_message')} + + )} + + {pendingCount > 0 && ( + navigate(`/groups/${group.slug}/members`)} + /> )} - ) - + ); }; const GroupsHomeCard = ({ groups }) => { const { t } = useTranslation(); + + const sortedGroups = _.sortBy( + group => + _.get('membersInfo.accountMembership.membershipStatus', group) === + MEMBERSHIP_STATUS_APPROVED, + groups + ); + return ( { {t('group.home_title')} - {groups.map((group, index) => ( + {sortedGroups.map((group, index) => ( {index !== groups.length - 1 ? ( diff --git a/src/group/groupContext.js b/src/group/groupContext.js index bc73b7841..9f6d3a337 100644 --- a/src/group/groupContext.js +++ b/src/group/groupContext.js @@ -16,6 +16,8 @@ export const GroupContextProvider = props => { 'MemberLeaveButton', 'MemberRemoveButton', 'MemberJoinButton', + 'MemberRequestJoinButton', + 'MemberRequestCancelButton', 'updateOwner', ], props diff --git a/src/group/groupFragments.js b/src/group/groupFragments.js index 94200dc81..1812d1f85 100644 --- a/src/group/groupFragments.js +++ b/src/group/groupFragments.js @@ -34,12 +34,21 @@ export const groupFields = ` email website email + membershipType + } +`; + +export const groupMembersPending = ` + fragment groupMembersPending on GroupNode { + pending: memberships(membershipStatus: "pending") { + totalCount + } } `; export const groupMembersInfo = ` fragment groupMembersInfo on GroupNode { - memberships(first: ${MEMBERS_INFO_SAMPLE_SIZE}) { + memberships(first: ${MEMBERS_INFO_SAMPLE_SIZE}, membershipStatus: "approved") { totalCount edges { node { @@ -61,6 +70,7 @@ export const groupMembers = ` node { id userRole + membershipStatus user { ...userFields } @@ -78,6 +88,7 @@ export const accountMembership = ` node { id userRole + membershipStatus } } } diff --git a/src/group/groupService.js b/src/group/groupService.js index 1f5b15ae4..e8aa4dd8f 100644 --- a/src/group/groupService.js +++ b/src/group/groupService.js @@ -5,6 +5,7 @@ import { groupFields, groupMembers, groupMembersInfo, + groupMembersPending, } from 'group/groupFragments'; import * as terrasoApi from 'terrasoBackend/api'; @@ -44,6 +45,7 @@ export const fetchGroupToView = (slug, currentUser) => { node { ...groupFields ...groupMembersInfo + ...groupMembersPending ...accountMembership } } @@ -51,6 +53,7 @@ export const fetchGroupToView = (slug, currentUser) => { } ${groupFields} ${groupMembersInfo} + ${groupMembersPending} ${accountMembership} `; return terrasoApi @@ -199,7 +202,7 @@ export const removeMember = (member, currentUser) => { })); }; -export const updateMemberRole = ({ member, newRole }, currentUser) => { +export const updateMember = ({ member }, currentUser) => { const query = ` mutation updateMembership($input: MembershipUpdateMutationInput!) { updateMembership(input: $input) { @@ -214,7 +217,7 @@ export const updateMemberRole = ({ member, newRole }, currentUser) => { `; return terrasoApi .requestGraphQL(query, { - input: { id: member.membershipId, userRole: newRole }, + input: member, accountEmail: currentUser.email, }) .then(_.get('updateMembership.membership.group')) diff --git a/src/group/groupSlice.js b/src/group/groupSlice.js index 1df337e5d..833a1b2a1 100644 --- a/src/group/groupSlice.js +++ b/src/group/groupSlice.js @@ -71,9 +71,9 @@ export const removeMember = createAsyncThunk( 'group/removeMember', groupService.removeMember ); -export const updateMemberRole = createAsyncThunk( - 'group/updateMemberRole', - groupService.updateMemberRole +export const updateMember = createAsyncThunk( + 'group/updateMember', + groupService.updateMember ); export const saveGroup = createAsyncThunk( 'group/saveGroup', @@ -87,18 +87,18 @@ export const saveGroup = createAsyncThunk( export const joinGroup = createAsyncThunk( 'group/joinGroup', groupService.joinGroup, - (group, { ownerName }) => ({ + (group, { ownerName, successMessage }) => ({ severity: 'success', - content: 'group.join_success', + content: successMessage, params: { name: ownerName }, }) ); export const leaveGroup = createAsyncThunk( 'group/leaveGroup', groupService.leaveGroup, - (group, { ownerName }) => ({ + (group, { ownerName, successMessage }) => ({ severity: 'success', - content: 'group.leave_success', + content: successMessage, params: { name: ownerName }, }) ); @@ -278,9 +278,9 @@ const groupSlice = createSlice({ ), [removeMember.rejected]: (state, action) => _.set(`members.list.${action.meta.arg.id}.fetching`, false, state), - [updateMemberRole.pending]: (state, action) => + [updateMember.pending]: (state, action) => _.set(`members.list.${action.meta.arg.member.id}.fetching`, true, state), - [updateMemberRole.fulfilled]: (state, action) => + [updateMember.fulfilled]: (state, action) => _.set( 'members', { @@ -289,7 +289,7 @@ const groupSlice = createSlice({ }, state ), - [updateMemberRole.rejected]: (state, action) => + [updateMember.rejected]: (state, action) => _.set(`members.list.${action.meta.arg.member.id}.fetching`, false, state), [saveGroup.pending]: state => ({ ...state, diff --git a/src/group/groupUtils.js b/src/group/groupUtils.js index 46469167c..9fed45288 100644 --- a/src/group/groupUtils.js +++ b/src/group/groupUtils.js @@ -2,6 +2,7 @@ import _ from 'lodash/fp'; export const extractMembersInfo = group => ({ totalCount: _.get('memberships.totalCount', group), + pendingCount: _.get('pending.totalCount', group), accountMembership: extractAccountMembership(group), membersSample: extractMembers(group), }); @@ -10,6 +11,7 @@ export const extractMembers = group => _.getOr([], 'memberships.edges', group).map(edge => ({ membershipId: _.get('node.id', edge), role: _.get('node.userRole', edge), + membershipStatus: _.get('node.membershipStatus', edge), ..._.get('node.user', edge), })); @@ -22,7 +24,7 @@ export const getMemberships = groups => _.fromPairs )(groups); -export const generateIndexedMembers = _.keyBy(member => member.id); +export const generateIndexedMembers = _.keyBy(member => member.membershipId); export const extractDataEntries = group => _.getOr([], 'dataEntries.edges', group).map(_.get('node')); diff --git a/src/group/membership/components/GroupMemberRequestCancel.js b/src/group/membership/components/GroupMemberRequestCancel.js new file mode 100644 index 000000000..a56ee9faf --- /dev/null +++ b/src/group/membership/components/GroupMemberRequestCancel.js @@ -0,0 +1,29 @@ +import React from 'react'; + +import _ from 'lodash/fp'; +import { useTranslation } from 'react-i18next'; + +import ConfirmButton from 'common/components/ConfirmButton'; + +const GroupMemberRequestCancel = props => { + const { t } = useTranslation(); + const { label, owner, onConfirm, buttonProps, loading } = props; + + return ( + + ); +}; + +export default GroupMemberRequestCancel; diff --git a/src/group/membership/components/GroupMembers.js b/src/group/membership/components/GroupMembers.js index 956793e10..51b98b268 100644 --- a/src/group/membership/components/GroupMembers.js +++ b/src/group/membership/components/GroupMembers.js @@ -11,6 +11,7 @@ import { Typography } from '@mui/material'; import { useDocumentTitle } from 'common/document'; import PageContainer from 'layout/PageContainer'; import PageHeader from 'layout/PageHeader'; +import PageLoader from 'layout/PageLoader'; import { GroupContextProvider } from 'group/groupContext'; import { fetchGroupForMembers } from 'group/groupSlice'; @@ -27,9 +28,7 @@ const MemberLeaveButton = withProps(GroupMemberLeave, { }); const Header = () => { - const dispatch = useDispatch(); const { t } = useTranslation(); - const { slug } = useParams(); const { data: group, fetching } = useSelector( state => state.group.membersGroup ); @@ -41,10 +40,6 @@ const Header = () => { fetching ); - useEffect(() => { - dispatch(fetchGroupForMembers(slug)); - }, [dispatch, slug]); - const { loading: loadingPermissions, allowed } = usePermission( 'group.manageMembers', group @@ -84,8 +79,19 @@ const Header = () => { }; const GroupMembers = () => { + const dispatch = useDispatch(); const { slug } = useParams(); - const { data: group } = useSelector(state => state.group.membersGroup); + const { data: group, fetching } = useSelector( + state => state.group.membersGroup + ); + + useEffect(() => { + dispatch(fetchGroupForMembers(slug)); + }, [dispatch, slug]); + + if (fetching) { + return ; + } return ( @@ -93,6 +99,7 @@ const GroupMembers = () => { diff --git a/src/group/membership/components/GroupMembers.test.js b/src/group/membership/components/GroupMembers.test.js new file mode 100644 index 000000000..bb7eb5bda --- /dev/null +++ b/src/group/membership/components/GroupMembers.test.js @@ -0,0 +1,452 @@ +import { fireEvent, render, screen, waitFor, within } from 'tests/utils'; + +import React from 'react'; + +import _ from 'lodash/fp'; +import { act } from 'react-dom/test-utils'; + +import useMediaQuery from '@mui/material/useMediaQuery'; + +import GroupMembers from 'group/membership/components/GroupMembers'; +import * as terrasoApi from 'terrasoBackend/api'; + +// Omit console error for DataGrid issue: https://github.com/mui/mui-x/issues/3850 +global.console.error = jest.fn(); + +jest.mock('terrasoBackend/api'); + +jest.mock('@mui/material/useMediaQuery'); + +const setup = async initialState => { + await render(, { + account: { + hasToken: true, + currentUser: { + fetching: false, + data: { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@email.com', + }, + }, + }, + ...initialState, + }); +}; + +test('GroupMembers: Display error', async () => { + terrasoApi.requestGraphQL.mockRejectedValue('Load error'); + await setup(); + expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(1); + expect(screen.getByText(/Load error/i)).toBeInTheDocument(); +}); +test('GroupMembers: Display loader', async () => { + terrasoApi.requestGraphQL.mockReturnValue(new Promise(() => {})); + await setup(); + const loader = screen.getByRole('progressbar', { + name: 'Loading', + hidden: true, + }); + expect(loader).toBeInTheDocument(); +}); +test('GroupMembers: Empty', async () => { + terrasoApi.requestGraphQL.mockReturnValue( + Promise.resolve( + _.set( + 'groups.edges[0].node', + { + name: 'Group Name', + }, + {} + ) + ) + ); + await setup(); + expect(screen.getByText(/No members/i)).toBeInTheDocument(); +}); +test('GroupMembers: Display list', async () => { + const generateMemberhips = (index, count) => ({ + edges: Array(count) + .fill(0) + .map((i, index) => + _.flow( + _.set('node.user', { + id: `index-${index}`, + firstName: 'Member name', + lastName: 'Member Last Name', + email: + index === 0 ? 'john.doe@email.com' : `email${index}@email.com`, + }), + _.set('node.userRole', 'member'), + _.set('node.id', `membership-${index}`), + _.set('node.membershipStatus', 'APPROVED') + )({}) + ), + }); + + const group = { + slug: 'test-group-slug', + name: 'Group Name', + memberships: generateMemberhips(3, 20), + }; + + terrasoApi.requestGraphQL + .mockReturnValueOnce( + Promise.resolve(_.set('groups.edges[0].node', group, {})) + ) + .mockReturnValueOnce( + Promise.resolve(_.set('groups.edges[0].node', group, {})) + ); + await setup(); + + // Group info + expect( + screen.getByRole('heading', { name: 'Group Name Members' }) + ).toBeInTheDocument(); + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(16); // 15 displayed + header + expect( + within(rows[2]).getByRole('cell', { + name: 'Member name Member Last Name Member name Member Last Name', + }) + ).toHaveAttribute('data-field', 'name'); + expect( + within(rows[9]).queryByRole('button', { name: 'Member' }) + ).not.toBeInTheDocument(); + expect(within(rows[9]).getByRole('cell', { name: 'Member' })).toHaveAttribute( + 'data-field', + 'role' + ); + expect(within(rows[2]).getByRole('cell', { name: '' })).toHaveAttribute( + 'data-field', + 'actions' + ); + expect(within(rows[1]).getByRole('cell', { name: 'Leave' })).toHaveAttribute( + 'data-field', + 'actions' + ); +}); +test('GroupMembers: Display list (small)', async () => { + useMediaQuery.mockReturnValue(true); + const generateMemberhips = (index, count) => ({ + edges: Array(count) + .fill(0) + .map((i, index) => + _.flow( + _.set('node.user', { + id: `index-${index}`, + firstName: 'Member name', + lastName: 'Member Last Name', + email: + index === 0 ? 'john.doe@email.com' : `email${index}@email.com`, + }), + _.set('node.userRole', 'member'), + _.set('node.id', `membership-${index}`), + _.set('node.membershipStatus', 'APPROVED') + )({}) + ), + }); + + const group = { + slug: 'test-group-slug', + name: 'Group Name', + memberships: generateMemberhips(3, 20), + }; + + terrasoApi.requestGraphQL + .mockReturnValueOnce( + Promise.resolve(_.set('groups.edges[0].node', group, {})) + ) + .mockReturnValueOnce( + Promise.resolve(_.set('groups.edges[0].node', group, {})) + ); + await setup(); + + // Group info + expect( + screen.getByRole('heading', { name: 'Group Name Members' }) + ).toBeInTheDocument(); + const rows = screen.getAllByRole('listitem'); + expect(rows.length).toBe(20); + expect( + within(rows[1]).getByText('Member name Member Last Name') + ).toBeInTheDocument(); + expect(within(rows[8]).getByText('Member')).toBeInTheDocument(); + expect(within(rows[0]).getByText('Member')).toBeInTheDocument(); +}); +test('GroupMembers: Display list manager', async () => { + const generateMemberhips = (index, count) => ({ + edges: Array(count) + .fill(0) + .map((i, index) => + _.flow( + _.set('node.user', { + id: `index-${index}`, + firstName: 'Member name', + lastName: 'Member Last Name', + email: 'email@email.com', + }), + _.set('node.userRole', 'MEMBER'), + _.set('node.id', `membership-${index}`), + _.set('node.membershipStatus', 'APPROVED') + )({}) + ), + }); + + const group = { + slug: 'test-group-slug', + name: 'Group Name', + memberships: generateMemberhips(3, 57), + accountMembership: _.set( + 'edges[0].node', + { userRole: 'MANAGER', membershipStatus: 'APPROVED' }, + {} + ), + }; + + terrasoApi.requestGraphQL + .mockReturnValueOnce( + Promise.resolve(_.set('groups.edges[0].node', group, {})) + ) + .mockReturnValueOnce( + Promise.resolve(_.set('groups.edges[0].node', group, {})) + ); + await setup(); + + // Group info + expect( + screen.getByRole('heading', { name: 'Manage Members' }) + ).toBeInTheDocument(); + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(16); // 15 displayed + header + expect( + within(rows[2]).getByRole('cell', { + name: 'Member name Member Last Name Member name Member Last Name', + }) + ).toHaveAttribute('data-field', 'name'); + expect(within(rows[9]).getByRole('cell', { name: 'Member' })).toHaveAttribute( + 'data-field', + 'role' + ); + expect( + within(rows[9]).getByRole('button', { name: 'Member' }) + ).toBeInTheDocument(); + expect(within(rows[2]).getByRole('cell', { name: 'Remove' })).toHaveAttribute( + 'data-field', + 'actions' + ); +}); +test('GroupMembers: Manager actions', async () => { + const generateMemberhips = (approved, pending) => ({ + edges: [ + ...Array(approved) + .fill(0) + .map((i, index) => + _.flow( + _.set('node.user', { + id: `index-approved-${index}`, + firstName: `Member name ${index}`, + lastName: 'Member Last Name', + email: 'email@email.com', + }), + _.set('node.userRole', 'MEMBER'), + _.set('node.id', `membership-approved-${index}`), + _.set('node.membershipStatus', 'APPROVED') + )({}) + ), + ...Array(pending) + .fill(0) + .map((i, index) => + _.flow( + _.set('node.user', { + id: `index-pending-${index}`, + firstName: `Member name Pending ${index}`, + lastName: 'Member Last Name', + email: 'email@email.com', + }), + _.set('node.userRole', 'MEMBER'), + _.set('node.id', `membership-pending-${index}`), + _.set('node.membershipStatus', 'PENDING') + )({}) + ), + ], + }); + + const group = { + slug: 'test-group-slug', + name: 'Group Name', + memberships: generateMemberhips(3, 2), + accountMembership: _.set( + 'edges[0].node', + { userRole: 'MANAGER', membershipStatus: 'APPROVED' }, + {} + ), + }; + + terrasoApi.requestGraphQL + .mockReturnValueOnce( + Promise.resolve(_.set('groups.edges[0].node', group, {})) + ) + .mockReturnValueOnce( + Promise.resolve(_.set('groups.edges[0].node', group, {})) + ) + .mockReturnValueOnce( + Promise.resolve( + _.flow( + _.set('updateMembership.membership.group', group), + _.set( + 'updateMembership.membership.group.memberships.edges[2].node.userRole', + 'MANAGER' + ), + _.set( + 'updateMembership.membership.group.memberships.edges[2].node.id', + 'membership-approved-2' + ) + )({}) + ) + ) + .mockReturnValueOnce( + Promise.resolve( + _.flow( + _.set('deleteMembership.membership.group', group), + _.set( + 'deleteMembership.membership.group.memberships.edges', + group.memberships.edges + .map(member => + member.node.id === 'membership-approved-2' ? null : member + ) + .filter(member => member) + ) + )({}) + ) + ) + .mockReturnValueOnce( + Promise.resolve( + _.flow( + _.set('deleteMembership.membership.group', group), + _.set( + 'deleteMembership.membership.group.memberships.edges', + group.memberships.edges + .map(member => + member.node.id === 'membership-pending-0' ? null : member + ) + .filter(member => member) + ) + )({}) + ) + ) + .mockReturnValueOnce( + Promise.resolve( + _.flow( + _.set('updateMembership.membership.group', group), + _.set( + 'updateMembership.membership.group.memberships.edges[4].node.membershipStatus', + 'APPROVED' + ) + )({}) + ) + ); + + await setup(); + + // Group info + expect( + screen.getByRole('heading', { name: 'Manage Members' }) + ).toBeInTheDocument(); + const listSection = screen.getByRole('region', { + name: 'Current Members', + }); + const rows = within(listSection).getAllByRole('row'); + + expect( + within(rows[3]).getByRole('cell', { + name: 'Member name 2 Member Last Name Member name 2 Member Last Name', + }) + ).toHaveAttribute('data-field', 'name'); + + // Role Change + expect(within(rows[3]).getByRole('cell', { name: 'Member' })).toHaveAttribute( + 'data-field', + 'role' + ); + const roleButton = within(rows[3]).getByRole('button', { name: 'Member' }); + expect(roleButton).toBeInTheDocument(); + await act(async () => fireEvent.mouseDown(roleButton)); + await act( + async () => + await fireEvent.click(screen.getByRole('option', { name: 'Manager' })) + ); + await waitFor(() => + expect( + within(rows[3]).getByRole('cell', { name: 'Manager' }) + ).toHaveAttribute('data-field', 'role') + ); + + // Remove member + expect(within(listSection).getAllByRole('row').length).toBe(4); + const removeButton = within(rows[3]).getByRole('button', { name: 'Remove' }); + await act(async () => fireEvent.click(removeButton)); + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Remove Member' })) + ); + await waitFor(() => + expect(within(listSection).getAllByRole('row').length).toBe(3) + ); + + // Pending members + const pendingSection = within( + screen.getByRole('region', { + name: 'Pending Members', + }) + ); + expect(pendingSection.getAllByRole('listitem').length).toBe(2); + + // Reject + expect( + within(pendingSection.getAllByRole('listitem')[0]).getByRole('img', { + name: 'Member name Pending 0 Member Last Name', + }) + ).toBeInTheDocument(); + await act( + async () => + await fireEvent.click( + within(pendingSection.getAllByRole('listitem')[0]).getByRole('button', { + name: 'Deny', + }) + ) + ); + await act( + async () => + await fireEvent.click( + screen.getByRole('button', { name: 'Deny Request' }) + ) + ); + await waitFor(() => + expect(pendingSection.getAllByRole('listitem').length).toBe(1) + ); + + // Approve + expect( + within(pendingSection.getAllByRole('listitem')[0]).getByRole('img', { + name: 'Member name Pending 1 Member Last Name', + }) + ).toBeInTheDocument(); + await act( + async () => + await fireEvent.click( + within(pendingSection.getAllByRole('listitem')[0]).getByRole('button', { + name: 'Approve', + }) + ) + ); + + expect(pendingSection.getAllByRole('listitem').length).toBe(1); + expect( + within(pendingSection.getAllByRole('listitem')[0]).getByRole('img', { + name: 'Member name Pending 0 Member Last Name', + }) + ).toBeInTheDocument(); + await waitFor(() => + expect(within(listSection).getAllByRole('row').length).toBe(5) + ); +}); diff --git a/src/group/membership/components/GroupMembersList.js b/src/group/membership/components/GroupMembersList.js index b39934dee..55ef5d98d 100644 --- a/src/group/membership/components/GroupMembersList.js +++ b/src/group/membership/components/GroupMembersList.js @@ -1,21 +1,29 @@ import React, { useContext, useEffect } from 'react'; import _ from 'lodash/fp'; +import { usePermission } from 'permissions'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { useSearchParams } from 'react-router-dom'; -import { MenuItem, Select, Stack, Typography } from '@mui/material'; +import { LoadingButton } from '@mui/lab'; +import { ListItem, MenuItem, Select, Stack, Typography } from '@mui/material'; +import ConfirmButton from 'common/components/ConfirmButton'; +import List from 'common/components/List'; import TableResponsive from 'common/components/TableResponsive'; import PageLoader from 'layout/PageLoader'; import Restricted from 'permissions/components/Restricted'; import AccountAvatar from 'account/components/AccountAvatar'; import { useGroupContext } from 'group/groupContext'; -import { fetchMembers, removeMember, updateMemberRole } from 'group/groupSlice'; +import { fetchMembers, removeMember, updateMember } from 'group/groupSlice'; -import theme from 'theme'; +import GroupMembershipPendingWarning from './GroupMembershipPendingWarning'; +import { + MEMBERSHIP_STATUS_APPROVED, + MEMBERSHIP_STATUS_PENDING, +} from './groupMembershipConstants'; const ROLES = ['MEMBER', 'MANAGER']; @@ -87,6 +95,85 @@ const RemoveButton = ({ member }) => { ); }; +const MemberName = ({ member }) => { + const { t } = useTranslation(); + return ( + + + {t('user.full_name', { user: member })} + + ); +}; + +const PendingApprovals = () => { + const { t } = useTranslation(); + const { group, owner } = useGroupContext(); + const { pending, onMemberApprove, onMemberRemove } = useContext( + GroupMembersListContext + ); + const { allowed } = usePermission('group.manageMembers', group); + + if (!allowed || _.isEmpty(pending)) { + return null; + } + + return ( +
+ + {t('group.members_list_pending_title')} + + + + {pending.map(member => ( + + onMemberApprove(member)} + > + {t('group.members_list_pending_approve')} + + onMemberRemove(member)} + confirmTitle={t( + 'group.members_list_pending_confirmation_title' + )} + confirmMessage={t( + 'group.members_list_pending_confirmation_message', + { + userName: t('user.full_name', { user: member }), + name: _.get('name', owner), + } + )} + confirmButton={t( + 'group.members_list_pending_confirmation_button' + )} + buttonLabel={t('group.members_list_pending_reject')} + loading={member.fetching} + /> + + } + > + + + ))} + +
+ ); +}; + const GroupMembersList = () => { const dispatch = useDispatch(); const { t } = useTranslation(); @@ -107,14 +194,33 @@ const GroupMembersList = () => { dispatch(removeMember(member)); }; + const onUpdateMember = member => dispatch(updateMember({ member })); + const onMemberRoleChange = (member, newRole) => { - dispatch(updateMemberRole({ member, newRole })); + onUpdateMember({ + id: member.membershipId, + userRole: newRole, + }); }; + const onMemberApprove = member => { + onUpdateMember({ + id: member.membershipId, + membershipStatus: MEMBERSHIP_STATUS_APPROVED, + }); + }; + + const groupedByStatus = _.flow( + _.values, + _.groupBy('membershipStatus') + )(members); + const listContext = { onMemberRoleChange, onMemberRemove, - members: _.values(members), + onMemberApprove, + pending: groupedByStatus[MEMBERSHIP_STATUS_PENDING], + members: groupedByStatus[MEMBERSHIP_STATUS_APPROVED], }; const columns = [ @@ -127,17 +233,7 @@ const GroupMembersList = () => { cardRender: ({ row: member }) => ( {t('user.full_name', { user: member })} ), - renderCell: ({ row: member }) => ( - - - {t('user.full_name', { user: member })} - - ), + renderCell: ({ row: member }) => , }, { field: 'role', @@ -163,43 +259,50 @@ const GroupMembersList = () => { }, ]; + const membersTitle = t('group.members_list_title', { + name: _.get('name', owner), + }); + return ( - - - - {t('group.members_list_title', { name: _.get('name', owner) })} - - - - ( - - ), - }} - tableProps={{ - initialSort: [ - { - field: 'name', - sort: 'asc', - }, - ], - }} - /> + +
+ + + + {membersTitle} + + + + ( + + ), + }} + tableProps={{ + initialSort: [ + { + field: 'name', + sort: 'asc', + }, + ], + }} + /> +
); }; diff --git a/src/group/membership/components/GroupMembershipCard.js b/src/group/membership/components/GroupMembershipCard.js index b2026c5b2..0f2459838 100644 --- a/src/group/membership/components/GroupMembershipCard.js +++ b/src/group/membership/components/GroupMembershipCard.js @@ -1,12 +1,13 @@ import React from 'react'; import _ from 'lodash/fp'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { AvatarGroup, Box, + Button, Card, CardActions, CardContent, @@ -16,10 +17,17 @@ import { Typography, } from '@mui/material'; +import Restricted from 'permissions/components/Restricted'; + import AccountAvatar from 'account/components/AccountAvatar'; import { useGroupContext } from 'group/groupContext'; import GroupMembershipJoinLeaveButton from './GroupMembershipJoinLeaveButton'; +import GroupMembershipPendingWarning from './GroupMembershipPendingWarning'; +import { + MEMBERSHIP_CLOSED, + MEMBERSHIP_STATUS_PENDING, +} from './groupMembershipConstants'; import theme from 'theme'; @@ -37,7 +45,7 @@ const Loader = () => { const Content = props => { const { t } = useTranslation(); const { owner } = useGroupContext(); - const { membersInfo, fetching, onViewMembers } = props; + const { membersInfo, fetching, group, onViewMembers } = props; const membersSample = _.getOr([], 'membersSample', membersInfo); const totalCount = _.getOr(0, 'totalCount', membersInfo); @@ -46,6 +54,46 @@ const Content = props => { return ; } + const userMembership = _.get('membersInfo.accountMembership', group); + const pendingRequest = + userMembership && + userMembership.membershipStatus === MEMBERSHIP_STATUS_PENDING; + const closedGroup = group?.membershipType === MEMBERSHIP_CLOSED; + + if (pendingRequest) { + return ( + + + + prefix + bold + + + + ); + } + + if (!userMembership && closedGroup) { + return ( + + + + {{ name: owner.name }} description + + + prefix + + link + + + + + ); + } + return ( @@ -76,7 +124,7 @@ const Content = props => { const GroupMembershipCard = props => { const { t } = useTranslation(); const { groupSlug } = useGroupContext(); - const { onViewMembers } = props; + const { onViewMembers, InfoComponent } = props; const { fetching, group } = useSelector( _.getOr({}, `group.memberships.${groupSlug}`) ); @@ -103,15 +151,40 @@ const GroupMembershipCard = props => { } /> {fetching ? null : ( - + ( + <> + + {InfoComponent && } + + )} + > + + )} + + {membersInfo.pendingCount > 0 && ( + + + + )} + ); }; diff --git a/src/group/membership/components/GroupMembershipCard.test.js b/src/group/membership/components/GroupMembershipCard.test.js index 5ba9f362e..f4ea88314 100644 --- a/src/group/membership/components/GroupMembershipCard.test.js +++ b/src/group/membership/components/GroupMembershipCard.test.js @@ -11,6 +11,8 @@ import GroupMemberLeave from 'group/membership/components/GroupMemberLeave'; import GroupMembershipCard from 'group/membership/components/GroupMembershipCard'; import * as terrasoApi from 'terrasoBackend/api'; +import GroupMemberRequestCancel from './GroupMemberRequestCancel'; + jest.mock('terrasoBackend/api'); const setup = async initialState => { @@ -19,6 +21,9 @@ const setup = async initialState => { owner={{ name: 'Owner Name', }} + group={{ + membershipType: 'OPEN', + }} groupSlug="group-slug" MemberJoinButton={props => ( @@ -26,6 +31,12 @@ const setup = async initialState => { MemberLeaveButton={props => ( 'Leave Label'} {...props} /> )} + MemberRequestJoinButton={props => ( + + )} + MemberRequestCancelButton={props => ( + + )} >
, @@ -200,6 +211,70 @@ test('GroupMembershipCard: Join', async () => { expect(() => screen.getByRole('progressbar')).toThrow(); expect(() => screen.getByRole('button', { name: 'Join Label' })).toThrow(); }); +test('GroupMembershipCard: Request Join', async () => { + terrasoApi.requestGraphQL.mockReturnValueOnce( + Promise.resolve({ + addMembership: { + membership: { + group: { + slug: 'group-slug', + membershipType: 'CLOSED', + accountMembership: _.flow( + _.set('edges[0].node.userRole', 'MEMBER'), + _.set('edges[0].node.membershipStatus', 'PENDING') + )({}), + memberships: { + totalCount: 1, + edges: [ + { + node: { + user: { + email: 'email@email.com', + firstName: 'First', + lastName: 'Last', + }, + }, + }, + ], + }, + }, + }, + }, + }) + ); + await setup({ + group: { + memberships: { + 'group-slug': { + group: { + slug: 'group-slug', + membershipType: 'CLOSED', + }, + }, + }, + }, + }); + expect( + screen.getByText( + 'Owner Name is a closed group, so the member list is not visible to non-members.' + ) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Request Join Label' }) + ).toBeInTheDocument(); + await act(async () => + fireEvent.click(screen.getByRole('button', { name: 'Request Join Label' })) + ); + expect(terrasoApi.requestGraphQL).toHaveBeenCalledTimes(1); + expect( + screen.getByText(/Your request has been sent to group manager/i) + ).toBeInTheDocument(); + expect(() => screen.getByRole('progressbar')).toThrow(); + expect(() => screen.getByRole('button', { name: 'Join Label' })).toThrow(); + expect( + screen.getByRole('button', { name: 'Request Cancel Label' }) + ).toBeInTheDocument(); +}); test('GroupMembershipCard: Leave error', async () => { terrasoApi.requestGraphQL.mockRejectedValueOnce('Leave error'); await setup({ @@ -310,3 +385,47 @@ test('GroupMembershipCard: Leave', async () => { screen.getByRole('button', { name: 'Join Label' }) ).toBeInTheDocument(); }); +test('GroupMembershipCard: Manager', async () => { + terrasoApi.requestGraphQL.mockReturnValueOnce( + Promise.resolve({ + deleteMembership: { + membership: { + group: { + slug: 'group-slug', + }, + }, + }, + }) + ); + await setup({ + group: { + memberships: { + 'group-slug': { + group: { + slug: 'group-slug', + membersInfo: { + totalCount: 1, + pendingCount: 2, + membersSample: [ + { + membershipId: 'membership-id', + email: 'email@email.com', + firstName: 'John', + lastName: 'Doe', + }, + ], + accountMembership: { + userRole: 'MANAGER', + membershipStatus: 'APPROVED', + }, + }, + }, + }, + }, + }, + }); + expect(screen.getByText(/2 pending members/i)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Manage Members' }) + ).toBeInTheDocument(); +}); diff --git a/src/group/membership/components/GroupMembershipInfo.js b/src/group/membership/components/GroupMembershipInfo.js new file mode 100644 index 000000000..c4dbd1bae --- /dev/null +++ b/src/group/membership/components/GroupMembershipInfo.js @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; + +import { Trans, useTranslation } from 'react-i18next'; + +import CloseIcon from '@mui/icons-material/CancelPresentation'; +import InfoIcon from '@mui/icons-material/InfoOutlined'; +import { + Dialog, + DialogContent, + IconButton, + Stack, + Typography, +} from '@mui/material'; + +const GroupMembershipInfo = () => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)} aria-label="delete"> + + + setOpen(false)}> + + setOpen(false)} + sx={{ + position: 'absolute', + right: 8, + top: 8, + color: theme => theme.palette.grey[500], + }} + > + + + + {t('group.membership_card_info_closed_title')} + + + + Step 1 + Step 2 + + + + {t('group.membership_card_info_open_title')} + + + {t('group.membership_card_info_open_description')} + + + + + ); +}; + +export default GroupMembershipInfo; diff --git a/src/group/membership/components/GroupMembershipJoinLeaveButton.js b/src/group/membership/components/GroupMembershipJoinLeaveButton.js index c03e4794d..1c3f8670a 100644 --- a/src/group/membership/components/GroupMembershipJoinLeaveButton.js +++ b/src/group/membership/components/GroupMembershipJoinLeaveButton.js @@ -8,11 +8,23 @@ import { useAnalytics } from 'monitoring/analytics'; import { useGroupContext } from 'group/groupContext'; import { joinGroup, leaveGroup } from 'group/groupSlice'; +import { + MEMBERSHIP_CLOSED, + MEMBERSHIP_STATUS_PENDING, +} from './groupMembershipConstants'; + const GroupMembershipJoinLeaveButton = () => { const dispatch = useDispatch(); const { trackEvent } = useAnalytics(); - const { owner, groupSlug, MemberLeaveButton, MemberJoinButton, updateOwner } = - useGroupContext(); + const { + owner, + groupSlug, + MemberLeaveButton, + MemberJoinButton, + MemberRequestJoinButton, + MemberRequestCancelButton, + updateOwner, + } = useGroupContext(); const { data: { email: userEmail }, } = useSelector(state => state.account.currentUser); @@ -24,32 +36,60 @@ const GroupMembershipJoinLeaveButton = () => { const userMembership = _.get('membersInfo.accountMembership', group); - const onJoin = () => { + const onJoin = successMessage => () => { trackEvent('joinGroup', { props: { group: groupSlug } }); dispatch( joinGroup({ groupSlug, userEmail, ownerName: owner.name, + successMessage, }) ).then(() => updateOwner?.()); }; - const onLeave = () => { + const onRemove = successMessage => () => { trackEvent('leaveGroup', { props: { group: groupSlug } }); dispatch( leaveGroup({ groupSlug, membershipId: userMembership.id, ownerName: owner.name, + successMessage, }) ).then(() => updateOwner?.()); }; + if ( + userMembership && + userMembership.membershipStatus === MEMBERSHIP_STATUS_PENDING + ) { + return ( + + ); + } if (userMembership) { - return ; + return ( + + ); } - return ; + if (group?.membershipType === MEMBERSHIP_CLOSED) { + return ( + + ); + } + return ( + + ); }; export default GroupMembershipJoinLeaveButton; diff --git a/src/group/membership/components/GroupMembershipPendingWarning.js b/src/group/membership/components/GroupMembershipPendingWarning.js new file mode 100644 index 000000000..a686ee70b --- /dev/null +++ b/src/group/membership/components/GroupMembershipPendingWarning.js @@ -0,0 +1,36 @@ +import React from 'react'; + +import { Trans } from 'react-i18next'; + +import WarningIcon from '@mui/icons-material/Warning'; +import { Link, Stack, Typography } from '@mui/material'; + +import { withProps } from 'react-hoc'; + +const GroupMembershipPendingWarning = props => { + const { count, onPendingClick, link = false, sx } = props; + + const onClick = event => { + onPendingClick(); + event.stopPropagation(); + event.preventDefault(); + }; + + const CountComponent = link + ? withProps(Link, { href: '#', onClick }) + : React.Fragment; + + return ( + + + + + link + text + + + + ); +}; + +export default GroupMembershipPendingWarning; diff --git a/src/group/membership/components/groupMembershipConstants.js b/src/group/membership/components/groupMembershipConstants.js new file mode 100644 index 000000000..79b13cdca --- /dev/null +++ b/src/group/membership/components/groupMembershipConstants.js @@ -0,0 +1,5 @@ +export const MEMBERSHIP_CLOSED = 'CLOSED'; +export const MEMBERSHIP_OPEN = 'OPEN'; + +export const MEMBERSHIP_STATUS_PENDING = 'PENDING'; +export const MEMBERSHIP_STATUS_APPROVED = 'APPROVED'; diff --git a/src/home/components/Home.test.js b/src/home/components/Home.test.js index a88ae5c8b..b2519a7db 100644 --- a/src/home/components/Home.test.js +++ b/src/home/components/Home.test.js @@ -1,4 +1,4 @@ -import { render, screen } from 'tests/utils'; +import { render, screen, within } from 'tests/utils'; import React from 'react'; @@ -117,7 +117,11 @@ test('Home: Display groups', async () => { id: 'id-1', slug: 'id-1', name: 'Group 1', - accountMembership: _.set('edges[0].node.userRole', 'MEMBER', {}), + accountMembership: _.set( + 'edges[0].node', + { userRole: 'MEMBER', membershipStatus: 'APPROVED' }, + {} + ), }, }, ], @@ -129,7 +133,25 @@ test('Home: Display groups', async () => { id: 'id-2', slug: 'id-2', name: 'Group 2', - accountMembership: _.set('edges[0].node.userRole', 'MANAGER', {}), + accountMembership: _.set( + 'edges[0].node', + { userRole: 'MANAGER', membershipStatus: 'APPROVED' }, + {} + ), + pending: { totalCount: 1 }, + }, + }, + { + node: { + id: 'id-3', + slug: 'id-3', + name: 'Group 3', + accountMembership: _.set( + 'edges[0].node', + { userRole: 'MEMBER', membershipStatus: 'PENDING' }, + {} + ), + pending: { totalCount: 1 }, }, }, ], @@ -137,10 +159,23 @@ test('Home: Display groups', async () => { }) ); await setup(); - expect(screen.getByText('Group 1')).toBeInTheDocument(); - expect(screen.getByText('(Member)')).toBeInTheDocument(); - expect(screen.getByText('Group 2')).toBeInTheDocument(); - expect(screen.getByText('(Manager)')).toBeInTheDocument(); + + const list = within(screen.getByRole('region', { name: 'Groups' })); + const items = list.getAllByRole('listitem'); + expect(items.length).toBe(3); + + expect(within(items[0]).getByText('Group 3')).toBeInTheDocument(); + expect(within(items[0]).getByText('(Pending)')).toBeInTheDocument(); + expect( + within(items[0]).getByText('Waiting for the group manager’s approval') + ).toBeInTheDocument(); + + expect(within(items[1]).getByText('Group 1')).toBeInTheDocument(); + expect(within(items[1]).getByText('(Member)')).toBeInTheDocument(); + + expect(within(items[2]).getByText('Group 2')).toBeInTheDocument(); + expect(within(items[2]).getByText('(Manager)')).toBeInTheDocument(); + expect(within(items[2]).getByText('1 pending member')).toBeInTheDocument(); }); test('Home: Display defaults', async () => { fetchHomeData.mockReturnValue( diff --git a/src/home/homeService.js b/src/home/homeService.js index 24cd1f230..18af90f5a 100644 --- a/src/home/homeService.js +++ b/src/home/homeService.js @@ -1,7 +1,7 @@ import _ from 'lodash/fp'; -import { groupFields } from 'group/groupFragments'; -import { extractAccountMembership } from 'group/groupUtils'; +import { groupFields, groupMembersPending } from 'group/groupFragments'; +import { extractAccountMembership, extractMembersInfo } from 'group/groupUtils'; import { defaultGroup, landscapeFields } from 'landscape/landscapeFragments'; import * as terrasoApi from 'terrasoBackend/api'; @@ -34,6 +34,7 @@ export const fetchHomeData = email => { edges { node { ...groupFields + ...groupMembersPending ...accountMembership } } @@ -45,12 +46,14 @@ export const fetchHomeData = email => { edges { node { ...groupFields + ...groupMembersPending ...accountMembership } } } } ${groupFields} + ${groupMembersPending} ${landscapeFields} ${defaultGroup} `; @@ -65,7 +68,7 @@ export const fetchHomeData = email => { .filter(group => group) .map(group => ({ ..._.omit(['accountMembership'], group), - accountMembership: extractAccountMembership(group), + membersInfo: extractMembersInfo(group), })), landscapes: _.getOr([], 'landscapeGroups.edges', response) .flatMap(_.getOr([], 'node.associatedLandscapes.edges')) diff --git a/src/landscape/components/LandscapeList.js b/src/landscape/components/LandscapeList.js index 05f37fd99..05961ced0 100644 --- a/src/landscape/components/LandscapeList.js +++ b/src/landscape/components/LandscapeList.js @@ -89,7 +89,7 @@ const LandscapeList = () => { { field: 'location', headerName: t('landscape.list_column_location'), - flex: 1.5, + flex: 0.5, minWidth: 200, valueGetter: ({ row: landscape }) => landscape.location && countryNameForCode(landscape.location)?.name, @@ -124,6 +124,7 @@ const LandscapeList = () => { headerName: t('landscape.list_column_actions_description'), sortable: false, align: 'center', + flex: 1, cardSize: 6, getActions: ({ row: landscape }) => [ , diff --git a/src/landscape/components/LandscapeList.test.js b/src/landscape/components/LandscapeList.test.js index 266436033..e93a90dd3 100644 --- a/src/landscape/components/LandscapeList.test.js +++ b/src/landscape/components/LandscapeList.test.js @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, within } from 'tests/utils'; +import { fireEvent, render, screen, waitFor, within } from 'tests/utils'; import React from 'react'; @@ -166,7 +166,7 @@ test('LandscapeList: Search', async () => { ); await new Promise(r => setTimeout(r, 300)); const rows = screen.getAllByRole('row'); - expect(rows.length).toBe(7); // 10 to 15 displayed + header + await waitFor(() => expect(rows.length).toBe(7)); // 10 to 15 displayed + header }); test('LandscapeList: List sort', async () => { const isMember = { diff --git a/src/landscape/components/LandscapeView.test.js b/src/landscape/components/LandscapeView.test.js index 423d977eb..f7f9af708 100644 --- a/src/landscape/components/LandscapeView.test.js +++ b/src/landscape/components/LandscapeView.test.js @@ -65,6 +65,7 @@ const baseViewTest = async () => { { id: 'user-id', userRole: 'MEMBER', + membershipStatus: 'APPROVED', }, {} ); diff --git a/src/landscape/membership/components/LandscapeMembers.test.js b/src/landscape/membership/components/LandscapeMembers.test.js index 6878f77e6..e0d224e2f 100644 --- a/src/landscape/membership/components/LandscapeMembers.test.js +++ b/src/landscape/membership/components/LandscapeMembers.test.js @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, within } from 'tests/utils'; +import { fireEvent, render, screen, waitFor, within } from 'tests/utils'; import React from 'react'; @@ -77,7 +77,9 @@ test('LandscapeMembers: Display list', async () => { email: index === 0 ? 'john.doe@email.com' : `email${index}@email.com`, }), - _.set('node.userRole', 'member') + _.set('node.userRole', 'member'), + _.set('node.id', `membership-${index}`), + _.set('node.membershipStatus', 'APPROVED') )({}) ), }); @@ -154,7 +156,9 @@ test('LandscapeMembers: Display list (small)', async () => { email: index === 0 ? 'john.doe@email.com' : `email${index}@email.com`, }), - _.set('node.userRole', 'member') + _.set('node.userRole', 'member'), + _.set('node.id', `membership-${index}`), + _.set('node.membershipStatus', 'APPROVED') )({}) ), }); @@ -214,7 +218,9 @@ test('LandscapeMembers: Display list manager', async () => { lastName: 'Member Last Name', email: 'email@email.com', }), - _.set('node.userRole', 'MEMBER') + _.set('node.userRole', 'MEMBER'), + _.set('node.id', `membership-${index}`), + _.set('node.membershipStatus', 'APPROVED') )({}) ), }); @@ -223,7 +229,11 @@ test('LandscapeMembers: Display list manager', async () => { slug: 'test-group-slug', name: 'Group Name', memberships: generateMemberhips(3, 57), - accountMembership: _.set('edges[0].node.userRole', 'MANAGER', {}), + accountMembership: _.set( + 'edges[0].node', + { userRole: 'MANAGER', membershipStatus: 'APPROVED' }, + {} + ), }; const landscape = { @@ -282,11 +292,13 @@ test('LandscapeMembers: Manager actions', async () => { _.flow( _.set('node.user', { id: `index-${index}`, - firstName: 'Member name', + firstName: `Member name ${index}`, lastName: 'Member Last Name', email: 'email@email.com', }), - _.set('node.userRole', 'MEMBER') + _.set('node.userRole', 'MEMBER'), + _.set('node.id', `membership-${index}`), + _.set('node.membershipStatus', 'APPROVED') )({}) ), }); @@ -295,7 +307,11 @@ test('LandscapeMembers: Manager actions', async () => { slug: 'test-group-slug', name: 'Group Name', memberships: generateMemberhips(3, 3), - accountMembership: _.set('edges[0].node.userRole', 'MANAGER', {}), + accountMembership: _.set( + 'edges[0].node', + { userRole: 'MANAGER', membershipStatus: 'APPROVED' }, + {} + ), }; const landscape = { @@ -330,6 +346,10 @@ test('LandscapeMembers: Manager actions', async () => { _.set( 'updateMembership.membership.group.memberships.edges[2].node.userRole', 'MANAGER' + ), + _.set( + 'updateMembership.membership.group.memberships.edges[2].node.membershipStatus', + 'APPROVED' ) )({}) ) @@ -365,9 +385,11 @@ test('LandscapeMembers: Manager actions', async () => { await act(async () => fireEvent.click(screen.getByRole('option', { name: 'Manager' })) ); - expect( - within(rows[3]).getByRole('cell', { name: 'Manager' }) - ).toHaveAttribute('data-field', 'role'); + await waitFor(() => + expect( + within(rows[3]).getByRole('cell', { name: 'Manager' }) + ).toHaveAttribute('data-field', 'role') + ); // Remove member expect(rows.length).toBe(4); @@ -376,6 +398,8 @@ test('LandscapeMembers: Manager actions', async () => { await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Remove Member' })) ); - const removedRows = screen.getAllByRole('row'); - expect(removedRows.length).toBe(3); + await screen.findByRole('region', { + name: 'Current Members', + }); + await waitFor(() => expect(screen.getAllByRole('row').length).toBe(3)); }); diff --git a/src/localization/locales/en-US.json b/src/localization/locales/en-US.json index 6a946b75c..99a1422c2 100644 --- a/src/localization/locales/en-US.json +++ b/src/localization/locales/en-US.json @@ -22,7 +22,7 @@ "membership_leave_confirm_title": "Leave “{{name}}”", "view_card_title": "About {{name}}", "list_title": "Groups", - "members_list_title": "{{name}} Members", + "members_list_title": "Current Members", "members_remove_confirmation_title": "Remove Member", "membership_leave_confirm_message": "Are you sure you want to leave the group “{{name}}”?", "members_remove_confirmation_message": "You are about to remove “{{userName}}” from the “{{name}}” group.", @@ -41,6 +41,7 @@ "members_title_member": "{{name}} Members", "members_description_member": "", "role_manager": "Manager", + "role_pending": "Pending", "members_title_manager": "Manage Members", "members_description_manager": "Add or remove members in the “{{name}}” group.", "home_connect_label": "Join another group", @@ -63,7 +64,9 @@ "added": "Added group “{{name}}.”", "updated": "Updated group “{{name}}.”", "join_success": "You joined “{{name}}”", + "request_success": "You requested to join the group “{{name}}”", "leave_success": "You left “{{name}}”", + "request_cancel_success": "You withdrew your request to join the group “{{name}}”", "not_found": "Group not found", "membership_card_description_other": "{{count}} Terraso members joined {{name}}.", "membership_card_description_one": "{{count}} Terraso member joined {{name}}.", @@ -86,7 +89,37 @@ "delete_not_allowed_manager_count": "$t(terraso_api.delete_not_allowed_base). There should at least be one manager.", "unique": "The group “{{body.variables.input.name}}” already exists.", "shared_data_upload_title": "Share Files with {{name}}", - "list_search_placeholder": "Search groups" + "list_search_placeholder": "Search groups", + "list_request_join_button": "Request to join", + "list_request_cancel_label": "Pending", + "view_request_join_button": "Request to join", + "view_request_cancel_label": "Cancel Request", + "membership_request_cancel_confirm_title": "Withdraw pending membership request", + "membership_request_cancel_confirm_message": "Would you like to withdraw your request to join {{name}}?", + "membership_request_cancel_confirm_button": "Withdraw Request", + "membership_open_option": "Open", + "membership_open_description": "Any Terraso member can join", + "membership_close_option": "Closed", + "membership_close_description": "Membership requires a group manager’s approval", + "form_membershipType_label": "Membership Type", + "membership_card_pending_description": "<0>Your request has been sent to group manager. <1>Your membership is currently pending.", + "membership_card_closed_description": "<0>{{name}} is a closed group, so the member list is not visible to non-members.<1>Request to join with a single click below. <1>Read more about group membership.", + "membership_card_closed_help_url": "https://terraso.org/help/groups/", + "membership_card_info_close": "Close", + "membership_card_info_closed_title": "How to join a closed group", + "membership_card_info_close_description": "<0>1. Click the “Request to Join.” Status will change to “Pending.”<1>2. Group manager will approve or deny the request.", + "membership_card_info_open_title": "How to join an open group", + "membership_card_info_open_description": "Click “Join Group.” You become a member immediately.", + "membership_card_manage_members": "Manage Members", + "members_list_pending_title": "Pending Members", + "members_pending_message_one": "<0><0>{{count}} pending member is waiting for your approval", + "members_pending_message_other": "<0><0>{{count}} pending members are waiting for your approval", + "members_list_pending_approve": "Approve", + "members_list_pending_confirmation_title": "Deny membership request?", + "members_list_pending_confirmation_message": "Do you want to deny {{userName}}’s request for membership in {{name}}?", + "members_list_pending_confirmation_button": "Deny Request", + "members_list_pending_reject": "Deny", + "home_pending_message": "Waiting for the group manager’s approval" }, "tool": { "requirements": "System requirements", diff --git a/src/localization/locales/es-ES.json b/src/localization/locales/es-ES.json index 607cc2a3d..d9e9bbef0 100644 --- a/src/localization/locales/es-ES.json +++ b/src/localization/locales/es-ES.json @@ -39,6 +39,7 @@ "members_title_member": "Miembros de {{name}}", "members_description_member": "", "role_manager": "Administrador", + "role_pending": "Pendiente", "members_title_manager": "Administrar miembros", "members_description_manager": "Agrega o elimina miembros del grupo “{{name}}”.", "home_connect_label": "Unirse a otro grupo", @@ -61,7 +62,9 @@ "added": "Grupo agregado “{{name}}.”", "updated": "Grupo actualizado “{{name}}.”", "join_success": "Se unió {{name}}", + "request_success": "Solicitaste unirte al grupo “{{name}}”", "leave_success": "Se abandonó {{name}}", + "request_cancel_success": "Has retirado tu solicitud para unirte al grupo “{{name}}”", "not_found": "Grupo no encontrado", "membership_card_description_other": "{{count}} miembros de Terraso se unieron a {{name}}.", "membership_card_description_one": "{{count}} miembro de Terraso se unió a {{name}}.", @@ -86,7 +89,37 @@ "delete_not_allowed_manager_count": "$t(terraso_api.delete_not_allowed_base). Debe existir al menos un administrador.", "unique": "El grupo “{{body.variables.input.name}}” ya existe.", "shared_data_upload_title": "Compartir archivos con {{name}}", - "list_search_placeholder": "Buscar grupos" + "list_search_placeholder": "Buscar grupos", + "list_request_join_button": "Solicitud para unirse", + "list_request_cancel_label": "Pendiente", + "view_request_join_button": "Solicitud para unirse", + "view_request_cancel_label": "Cancelar Solicitud", + "membership_request_cancel_confirm_title": "Retirar solicitud de membresía pendiente", + "membership_request_cancel_confirm_message": "¿Deseas retirar tu solicitud para unirte a {{name}}?", + "membership_request_cancel_confirm_button": "Retirar Solicitud", + "membership_open_option": "Abierto", + "membership_open_description": "Cualquier miembro de Terraso puede unirse", + "membership_close_option": "Cerrado", + "membership_close_description": "La membresía requiere la aprobación de un administrador de grupo", + "form_membershipType_label": "Tipo de membresía", + "membership_card_pending_description": "<0>Tu solicitud ha sido enviada al administrador del grupo. <1>Tu membresía está actualmente pendiente.", + "membership_card_closed_description": "<0>{{name}} es un grupo cerrado, por lo que la lista de miembros no es visible para los que no son miembros.<1>Solicita unirte con un solo clic a continuación. <1>Más información sobre la pertenencia a grupos.", + "membership_card_closed_help_url": "https://terraso.org/es/ayuda/groupos/", + "membership_card_info_close": "Cerrar", + "membership_card_info_closed_title": "¿Cómo unirse a un grupo cerrado?", + "membership_card_info_close_description": "<0>1. Has clic en ”Solicitud para unirse”. El estado cambiará a ”Pendiente”.<1>2. El administrador del grupo aprobará o denegará la solicitud.", + "membership_card_info_open_title": "¿Cómo unirse a un grupo abierto?", + "membership_card_info_open_description": "Has clic en ”Unirse al grupo”. Te convertirás en miembro inmediatamente.", + "membership_card_manage_members": "Administrar miembros", + "members_list_pending_title": "Miembros pendientes", + "members_pending_message_one": "<0><0>{{count}} miembro pendiente está esperando su aprobación", + "members_pending_message_other": "<0><0>{{count}} miembros pendientes están esperando tu aprobación", + "members_list_pending_approve": "Aprobar", + "members_list_pending_confirmation_title": "¿Rechazar la solicitud de membresía?", + "members_list_pending_confirmation_message": "¿Quieres denegar la solicitud de membresía de {{userName}} en {{name}}?", + "members_list_pending_confirmation_button": "Denegar solicitud", + "members_list_pending_reject": "Denegar", + "home_pending_message": "Esperando la aprobación del administrador del grupo" }, "tool": { "avilability": "Disponibilidad", diff --git a/src/permissions/rules.js b/src/permissions/rules.js index 6b5dc3606..6bc4a7d18 100644 --- a/src/permissions/rules.js +++ b/src/permissions/rules.js @@ -1,8 +1,38 @@ import _ from 'lodash/fp'; +import { MEMBERSHIP_STATUS_APPROVED } from 'group/membership/components/groupMembershipConstants'; + +const getAccountMembership = group => + _.getOr( + _.get('membersInfo.accountMembership', group), + 'accountMembership', + group + ); + +const isApprovedMember = group => { + const accountMembership = getAccountMembership(group); + if (!accountMembership || !accountMembership.userRole) { + return false; + } + + const isApproved = + accountMembership.membershipStatus === MEMBERSHIP_STATUS_APPROVED; + + return isApproved; +}; + +const hasRole = ({ group, role }) => { + const isMember = isApprovedMember(group); + if (!isMember) { + return false; + } + const accountMembership = getAccountMembership(group); + const hasRole = accountMembership.userRole === role; + return hasRole; +}; + const isAllowedToEditSharedData = ({ resource: { group, file }, user }) => { - const isManager = - _.get('membersInfo.accountMembership.userRole', group) === 'MANAGER'; + const isManager = hasRole({ group, role: 'MANAGER' }); const isOwner = _.get('createdBy.id', file) === _.get('id', user); return Promise.resolve(isManager || isOwner); }; @@ -12,40 +42,32 @@ const isAllowedToDeleteSharedData = ({ resource, user }) => { }; const isAllowedToDownloadSharedData = ({ resource: group }) => { - const isMember = Boolean( - _.get('membersInfo.accountMembership.userRole', group) - ); + const isMember = isApprovedMember(group); return Promise.resolve(isMember); }; const isAllowedToAddSharedData = ({ resource: group }) => { - const isMember = Boolean( - _.get('membersInfo.accountMembership.userRole', group) - ); + const isMember = isApprovedMember(group); return Promise.resolve(isMember); }; const isAllowedToChangeGroup = ({ resource: group }) => { - const isManager = _.get('accountMembership.userRole', group) === 'MANAGER'; + const isManager = hasRole({ group, role: 'MANAGER' }); return Promise.resolve(isManager); }; const isAllowedToManagerGroupMembers = ({ resource: group }) => { - const isManager = _.get('accountMembership.userRole', group) === 'MANAGER'; + const isManager = hasRole({ group, role: 'MANAGER' }); return Promise.resolve(isManager); }; const isAllowedToViewGroupSharedData = ({ resource: group }) => { - const isMember = Boolean( - _.get('membersInfo.accountMembership.userRole', group) - ); + const isMember = isApprovedMember(group); return Promise.resolve(isMember); }; const isAllowedToChangeLandscape = ({ resource: landscape }) => { - const isManager = - _.get('defaultGroup.membersInfo.accountMembership.userRole', landscape) === - 'MANAGER'; + const isManager = hasRole({ group: landscape.defaultGroup, role: 'MANAGER' }); return Promise.resolve(isManager); }; diff --git a/src/react-hoc.js b/src/react-hoc.js index 34f23341b..95033f60e 100644 --- a/src/react-hoc.js +++ b/src/react-hoc.js @@ -1,10 +1,11 @@ import React from 'react'; // Component with custom partial prop values -export const withProps = (Component, customProps) => props => { - const componentProps = { - ...customProps, - ...props, - }; - return ; -}; +export const withProps = (Component, customProps) => + React.forwardRef((props, ref) => { + const componentProps = { + ...customProps, + ...props, + }; + return ; + }); diff --git a/src/sharedData/components/SharedDataCard.js b/src/sharedData/components/SharedDataCard.js index 5568914ff..9350ebed5 100644 --- a/src/sharedData/components/SharedDataCard.js +++ b/src/sharedData/components/SharedDataCard.js @@ -11,32 +11,19 @@ import { CardContent, CardHeader, CircularProgress, - Divider, Link, - List, - Paper, - Stack, Typography, } from '@mui/material'; +import List from 'common/components/List'; + import { useGroupContext } from 'group/groupContext'; import { fetchGroupSharedData } from 'sharedData/sharedDataSlice'; import { SHARED_DATA_ACCEPTED_EXTENSIONS } from 'config'; -import { withProps } from 'react-hoc'; import SharedDataEntry from './SharedDataEntry'; -const EntriesList = withProps(List, { - component: withProps(Stack, { - divider: , - component: withProps(Paper, { - variant: 'outlined', - component: 'ul', - }), - }), -}); - const SharedFilesCard = props => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -79,11 +66,11 @@ const SharedFilesCard = props => { )} {hasFiles && ( - + {sharedFiles.map(file => ( ))} - +
)}