diff --git a/cypress/e2e/memberships/createItemMembership.cy.ts b/cypress/e2e/memberships/createItemMembership.cy.ts index 58d24da01..212d7f809 100644 --- a/cypress/e2e/memberships/createItemMembership.cy.ts +++ b/cypress/e2e/memberships/createItemMembership.cy.ts @@ -4,11 +4,15 @@ import { PermissionLevel, } from '@graasp/sdk'; -import { buildItemPath } from '../../../src/config/paths'; +import { buildItemPath, buildItemSharePath } from '../../../src/config/paths'; import { CREATE_MEMBERSHIP_FORM_ID, + ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS, + SHARE_BUTTON_SELECTOR, + SHARE_ITEM_CANCEL_BUTTON_CY, SHARE_ITEM_EMAIL_INPUT_ID, SHARE_ITEM_SHARE_BUTTON_ID, + buildDataCyWrapper, buildShareButtonId, } from '../../../src/config/selectors'; import { MEMBERS } from '../../fixtures/members'; @@ -69,7 +73,71 @@ describe('Create Membership', () => { cy.get(`#${SHARE_ITEM_EMAIL_INPUT_ID}`).should('be.empty'); }); - it('cannot share item twice', () => { + it('open share modal, cancel and sharing reset content', () => { + cy.setUpApi({ items: ITEMS, members: Object.values(MEMBERS) }); + + // go to children item + const { id } = FOLDER; + cy.visit(buildItemSharePath(id)); + + // open modal and cancel + cy.get(buildDataCyWrapper(SHARE_BUTTON_SELECTOR)).click(); + cy.get(buildDataCyWrapper(SHARE_ITEM_CANCEL_BUTTON_CY)).click(); + cy.get(`#${SHARE_ITEM_SHARE_BUTTON_ID}`).should('not.exist'); + + // share + const member = MEMBERS.FANNY; + const permission = PermissionLevel.Admin; + cy.fillShareForm({ + email: member.email, + permission, + selector: `#${CREATE_MEMBERSHIP_FORM_ID}`, + }); + + // check that fields are reset if reopen modal + cy.get(buildDataCyWrapper(SHARE_BUTTON_SELECTOR)).click(); + cy.get(`#${SHARE_ITEM_EMAIL_INPUT_ID}`).should('be.empty'); + cy.get(`.${ITEM_MEMBERSHIP_PERMISSION_SELECT_CLASS} input`).should( + 'have.value', + PermissionLevel.Read, + ); + }); + + it('share item with new admin by pressing enter', () => { + cy.setUpApi({ items: ITEMS, members: Object.values(MEMBERS) }); + + // go to children item + const { id } = FOLDER; + cy.visit(buildItemPath(id)); + + // share + const member = MEMBERS.FANNY; + const permission = PermissionLevel.Admin; + shareItem({ + id, + email: `${member.email}{Enter}`, + permission, + submit: false, + }); + + cy.wait('@postInvitations').then( + ({ + request: { + url, + body: { invitations }, + }, + }) => { + expect(url).to.contain(id); + expect(invitations[0].permission).to.equal(permission); + expect(invitations[0].email).to.equal(member.email); + }, + ); + + // check that the email field is emptied after sharing completes + cy.get(`#${SHARE_ITEM_EMAIL_INPUT_ID}`).should('be.empty'); + }); + + it('cannot add membership item twice', () => { const ITEM = PackedFolderItemFactory(); const account = MEMBERS.ANNA; cy.setUpApi({ @@ -99,6 +167,29 @@ describe('Create Membership', () => { cy.get(`#${SHARE_ITEM_SHARE_BUTTON_ID}`).should('be.disabled'); }); + it('cannot invite user twice', () => { + const ITEM = PackedFolderItemFactory(); + const { email } = MEMBERS.CEDRIC; + cy.setUpApi({ + items: [ + { + ...ITEM, + invitations: [{ email }], + }, + ], + members: Object.values(MEMBERS), + }); + + const { id } = ITEM; + cy.visit(buildItemPath(id)); + + // fill + const permission = PermissionLevel.Read; + shareItem({ id, email, permission }); + + cy.get(`#${SHARE_ITEM_SHARE_BUTTON_ID}`).should('be.disabled'); + }); + it('cannot share item with invalid data', () => { cy.setUpApi({ items: ITEMS, members: Object.values(MEMBERS) }); diff --git a/cypress/support/commands/item.ts b/cypress/support/commands/item.ts index 9e5b7c641..6cfad0991 100644 --- a/cypress/support/commands/item.ts +++ b/cypress/support/commands/item.ts @@ -49,9 +49,7 @@ Cypress.Commands.add( cy.get(`#${SHARE_ITEM_EMAIL_INPUT_ID}`).type(email); if (submit) { - // wait for email to be validated and enable the button - cy.wait(1000); - cy.get(`#${SHARE_ITEM_SHARE_BUTTON_ID}`).click('left'); + cy.get(`#${SHARE_ITEM_SHARE_BUTTON_ID}`).click(); } }, ); diff --git a/src/components/item/sharing/shareButton/CreateItemMembershipForm.tsx b/src/components/item/sharing/shareButton/CreateItemMembershipForm.tsx index 29c43cb35..35fd30c93 100644 --- a/src/components/item/sharing/shareButton/CreateItemMembershipForm.tsx +++ b/src/components/item/sharing/shareButton/CreateItemMembershipForm.tsx @@ -1,27 +1,25 @@ -import { useState } from 'react'; +import { useForm } from 'react-hook-form'; import { + Box, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, - TextFieldProps, Typography, } from '@mui/material'; -import { - AccountType, - DiscriminatedItem, - Invitation, - PermissionLevel, -} from '@graasp/sdk'; +import { AccountType, DiscriminatedItem, PermissionLevel } from '@graasp/sdk'; import { COMMON } from '@graasp/translations'; import { Button } from '@graasp/ui'; +import truncate from 'lodash.truncate'; import validator from 'validator'; +import { ITEM_NAME_MAX_LENGTH } from '@/config/constants'; + import { useBuilderTranslation, useCommonTranslation, @@ -29,104 +27,55 @@ import { import { hooks, mutations } from '../../../../config/queryClient'; import { CREATE_MEMBERSHIP_FORM_ID, + SHARE_ITEM_CANCEL_BUTTON_CY, SHARE_ITEM_EMAIL_INPUT_ID, SHARE_ITEM_SHARE_BUTTON_ID, } from '../../../../config/selectors'; import { BUILDER } from '../../../../langs/constants'; -import ItemMembershipSelect, { - ItemMembershipSelectProps, -} from '../ItemMembershipSelect'; +import ItemMembershipSelect from '../ItemMembershipSelect'; -type InvitationFieldInfoType = Pick; -type Props = { +type ContentProps = { item: DiscriminatedItem; - open: boolean; handleClose: () => void; }; -const CreateItemMembershipForm = ({ - item, - open, - handleClose, -}: Props): JSX.Element => { +type Inputs = { + email: string; + permission: PermissionLevel; +}; + +const Content = ({ handleClose, item }: ContentProps) => { const itemId = item.id; - const [error, setError] = useState(); const { mutateAsync: shareItem } = mutations.useShareItem(); const { data: memberships } = hooks.useItemMemberships(item.id); + const { data: invitations } = hooks.useItemInvitations(item.id); const { t: translateCommon } = useCommonTranslation(); const { t: translateBuilder } = useBuilderTranslation(); - // use an array to later allow sending multiple invitations - const [invitation, setInvitation] = useState({ - email: '', - permission: PermissionLevel.Read, - }); - - const checkForInvitationError = ({ - email, - }: { - email: string; - }): string | null => { - // check mail validity - if (!email) { - return translateBuilder( - BUILDER.SHARE_ITEM_FORM_INVITATION_EMPTY_EMAIL_MESSAGE, - ); - } - if (!validator.isEmail(email)) { - return translateBuilder( - BUILDER.SHARE_ITEM_FORM_INVITATION_INVALID_EMAIL_MESSAGE, - ); - } - // check mail does not already exist - if ( - memberships?.find( - ({ account }) => - account.type === AccountType.Individual && account.email === email, - ) - ) { - return translateBuilder( - BUILDER.SHARE_ITEM_FORM_INVITATION_EMAIL_EXISTS_MESSAGE, - ); - } - return null; - }; - - const onChangePermission: ItemMembershipSelectProps['onChange'] = (e) => { - setInvitation({ - ...invitation, - permission: e.target.value as PermissionLevel, - }); - }; - - const handleShare = async () => { - // not good to check email for multiple invitations at once - const invitationError = checkForInvitationError(invitation); - - if (invitationError) { - return setError(invitationError); - } + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ defaultValues: { permission: PermissionLevel.Read } }); + const permission = watch('permission'); + const handleShare = async (data: Inputs) => { let returnedValue; try { await shareItem({ itemId, invitations: [ { - email: invitation.email, - permission: invitation.permission, + email: data.email, + permission: data.permission, }, ], }); - // reset email input - setInvitation({ - ...invitation, - email: '', - }); - handleClose(); } catch (e) { console.error(e); @@ -134,21 +83,8 @@ const CreateItemMembershipForm = ({ return returnedValue; }; - const onChangeEmail: TextFieldProps['onChange'] = (event) => { - const newInvitation = { - ...invitation, - email: event.target.value, - }; - setInvitation(newInvitation); - if (error) { - const isInvalid = checkForInvitationError(newInvitation); - setError(isInvalid); - } - }; - return ( - - Share item + {translateBuilder(BUILDER.SHARE_ITEM_FORM_INVITATION_TOOLTIP)} @@ -164,29 +100,86 @@ const CreateItemMembershipForm = ({ id={SHARE_ITEM_EMAIL_INPUT_ID} variant="outlined" label={translateBuilder(BUILDER.SHARE_ITEM_FORM_EMAIL_LABEL)} - helperText={error} - value={invitation.email} - onChange={onChangeEmail} - error={Boolean(error)} + helperText={errors.email?.message} + {...register('email', { + required: true, + validate: { + isEmail: (email) => + validator.isEmail(email) || + translateBuilder( + BUILDER.SHARE_ITEM_FORM_INVITATION_INVALID_EMAIL_MESSAGE, + ), + noMembership: (email) => + !memberships?.some( + ({ account }) => + account.type === AccountType.Individual && + account.email === email, + ) || + translateBuilder( + BUILDER.SHARE_ITEM_FORM_ALREADY_HAVE_MEMBERSHIP_MESSAGE, + ), + noInvitation: (email) => + !invitations?.some((inv) => inv.email === email) || + translateBuilder( + BUILDER.SHARE_ITEM_FORM_INVITATION_EMAIL_EXISTS_MESSAGE, + ), + }, + })} + error={Boolean(errors.email)} sx={{ flexGrow: 1 }} /> { + if (event.target.value) { + setValue('permission', event.target.value as PermissionLevel); + } + }} size="medium" /> - + + + ); +}; + +type CreateItemMembershipFormProps = { + item: ContentProps['item']; + open: boolean; + handleClose: ContentProps['handleClose']; +}; + +const CreateItemMembershipForm = ({ + item, + open, + handleClose, +}: CreateItemMembershipFormProps): JSX.Element => { + const { t: translateBuilder } = useBuilderTranslation(); + + return ( + + + {translateBuilder(BUILDER.SHARE_ITEM_FORM_TITLE, { + name: truncate(item.name, { length: ITEM_NAME_MAX_LENGTH }), + })} + + ); }; diff --git a/src/config/constants.ts b/src/config/constants.ts index c628a3d20..5b6603fcb 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -35,7 +35,7 @@ export const HEADER_HEIGHT = 64; export const FILE_UPLOAD_MAX_FILES = 15; export const ITEMS_TABLE_ROW_ICON_COLOR = '#333333'; -export const ITEM_NAME_MAX_LENGTH = 15; +export const ITEM_NAME_MAX_LENGTH = 20; export const LOADING_CONTENT = '…'; export const SETTINGS = { diff --git a/src/config/selectors.ts b/src/config/selectors.ts index 341442427..a627a278c 100644 --- a/src/config/selectors.ts +++ b/src/config/selectors.ts @@ -450,3 +450,4 @@ export const MEMBERSHIP_REQUEST_REJECT_BUTTON_SELECTOR = 'membershipRequestRejectButton'; export const ENROLL_BUTTON_SELECTOR = 'enrollButton'; export const VISIBILITY_HIDDEN_ALERT_ID = 'visibilityHiddenAlert'; +export const SHARE_ITEM_CANCEL_BUTTON_CY = 'shareItemCancelButton'; diff --git a/src/langs/constants.ts b/src/langs/constants.ts index 20b01a1aa..b8d75aa80 100644 --- a/src/langs/constants.ts +++ b/src/langs/constants.ts @@ -204,6 +204,7 @@ export const BUILDER = { 'SHARE_ITEM_CSV_IMPORT_ERROR_MISSING_COLUMN', SHARE_ITEM_FORM_EMAIL_LABEL: 'SHARE_ITEM_FORM_EMAIL_LABEL', SHARE_ITEM_FORM_CONFIRM_BUTTON: 'SHARE_ITEM_FORM_CONFIRM_BUTTON', + SHARE_ITEM_FORM_TITLE: 'SHARE_ITEM_FORM_TITLE', SHARE_ITEM_FORM_INVITATION_TOOLTIP: 'SHARE_ITEM_FORM_INVITATION_TOOLTIP', SHARE_ITEM_CSV_SUMMARY_GROUP_TITLE: 'SHARE_ITEM_CSV_SUMMARY_GROUP_TITLE', SHARE_ITEM_CSV_SUMMARY_MODIFYING_EXISTING: @@ -211,6 +212,8 @@ export const BUILDER = { SHARE_ITEM_FORM_INVITATION_EMAIL_EXISTS_MESSAGE: 'SHARE_ITEM_FORM_INVITATION_EMAIL_EXISTS_MESSAGE', + SHARE_ITEM_FORM_ALREADY_HAVE_MEMBERSHIP_MESSAGE: + 'SHARE_ITEM_FORM_ALREADY_HAVE_MEMBERSHIP_MESSAGE', SHARE_ITEM_FORM_INVITATION_INVALID_EMAIL_MESSAGE: 'SHARE_ITEM_FORM_INVITATION_INVALID_EMAIL_MESSAGE', SHARE_ITEM_FORM_INVITATION_EMPTY_EMAIL_MESSAGE: diff --git a/src/langs/en.json b/src/langs/en.json index 328567d95..20661aef0 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -300,9 +300,11 @@ "SHARE_ITEM_CSV_IMPORT_SUCCESS_MESSAGE": "The users were successfully imported!", "SHARE_ITEM_FORM_CONFIRM_BUTTON": "Share", "SHARE_ITEM_FORM_EMAIL_LABEL": "Email", - "SHARE_ITEM_FORM_INVITATION_EMAIL_EXISTS_MESSAGE": "This user already has access to this item", + "SHARE_ITEM_FORM_INVITATION_EMAIL_EXISTS_MESSAGE": "This user has already been invited.", + "SHARE_ITEM_FORM_ALREADY_HAVE_MEMBERSHIP_MESSAGE": "This user already has access to this item.", "SHARE_ITEM_FORM_INVITATION_EMPTY_EMAIL_MESSAGE": "The mail cannot be empty", "SHARE_ITEM_FORM_INVITATION_INVALID_EMAIL_MESSAGE": "This mail is not valid", + "SHARE_ITEM_FORM_TITLE": "Share \"{{name}}\"", "SHARE_ITEM_FORM_INVITATION_TOOLTIP": "Non-registered users will receive a personal link to register on the platform.", "SHARE_ITEM_LINK_COPY_TOOLTIP": "Copy to Clipboard", "SHARE_ITEM_LINK_QR_CODE": "Generate QR Code", diff --git a/src/langs/fr.json b/src/langs/fr.json index 6fe02f5a5..4f39fd1c1 100644 --- a/src/langs/fr.json +++ b/src/langs/fr.json @@ -300,9 +300,11 @@ "SHARE_ITEM_CSV_IMPORT_SUCCESS_MESSAGE": "Les utilisateurs ont été importés avec succès", "SHARE_ITEM_FORM_CONFIRM_BUTTON": "Partager", "SHARE_ITEM_FORM_EMAIL_LABEL": "Email", - "SHARE_ITEM_FORM_INVITATION_EMAIL_EXISTS_MESSAGE": "Cet utilisateur a déjà accès à cet élément", + "SHARE_ITEM_FORM_INVITATION_EMAIL_EXISTS_MESSAGE": "Cet utilisateur a déjà été invité.", + "SHARE_ITEM_FORM_ALREADY_HAVE_MEMBERSHIP_MESSAGE": "Cet utilisateur a déjà accès à cet élément.", "SHARE_ITEM_FORM_INVITATION_EMPTY_EMAIL_MESSAGE": "L'email ne peut pas être vide", "SHARE_ITEM_FORM_INVITATION_INVALID_EMAIL_MESSAGE": "Cet email n'est pas valide", + "SHARE_ITEM_FORM_TITLE": "Partager \"{{name}}\"", "SHARE_ITEM_FORM_INVITATION_TOOLTIP": "Les utilisateurs non-enregistrés vont recevoir un lien personnel pour s'enregistrer sur la plateforme.", "SHARE_ITEM_LINK_COPY_TOOLTIP": "Copier dans le presse-papier", "SHARE_ITEM_LINK_QR_CODE": "Générer le code QR",