Merge pull request #33065 from overleaf/ii-share-modal-send-invites

[web] Add send invites input and role selection to share modal

GitOrigin-RevId: f43654e1ca0d8000b2327f1f398fd062ef1b74e4
This commit is contained in:
ilkin-overleaf
2026-05-04 13:22:09 +03:00
committed by Copybot
parent fdc939fe0a
commit 5727643852
13 changed files with 834 additions and 373 deletions

View File

@@ -576,6 +576,7 @@
"email_does_not_belong_to_university": "",
"email_limit_reached": "",
"email_link_expired": "",
"email_must_be_a_valid_format": "",
"email_must_be_linked_to_institution": "",
"email_notifications_are_currently_in_beta": "",
"email_or_password_wrong_try_again": "",
@@ -596,6 +597,7 @@
"end_time_utc": "",
"ensure_recover_account": "",
"enter_any_size_including_units_or_valid_latex_command": "",
"enter_emails_separated_by_commas": "",
"enter_manually": "",
"enter_tax_id_number": "",
"enter_the_code": "",
@@ -963,6 +965,7 @@
"invalid_upload_request": "",
"invert_pdf_preview_colors": "",
"invert_pdf_preview_colors_when_in_dark_mode": "",
"invitations_sent": "",
"invite": "",
"invite_expired": "",
"invite_group_members": "",
@@ -1275,6 +1278,7 @@
"on": "",
"on_free_plan_upgrade_to_access_features": "",
"one_step_away_from_professional_features": "",
"only_add_people_who_dont_yet_have_access": "",
"only_group_admin_or_managers_can_delete_your_account_1": "",
"only_group_admin_or_managers_can_delete_your_account_10": "",
"only_group_admin_or_managers_can_delete_your_account_3": "",

View File

@@ -0,0 +1,294 @@
import { useMemo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMultipleSelection } from 'downshift'
import { Select } from '@/shared/components/select'
import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
import OLButton from '@/shared/components/ol/ol-button'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { useProjectContext } from '@/shared/context/project-context'
import {
resendInvite,
sendInvite,
} from '@/features/share-project-modal/utils/api'
import { ContactItem } from '@/features/share-project-modal/components/select-collaborators'
import { useShareProjectContext } from '@/features/share-project-modal/components/share-project-modal'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { sendMB } from '@/infrastructure/event-tracking'
import MaterialIcon, {
AvailableUnfilledIcon,
} from '@/shared/components/material-icon'
import { isValidEmail } from '@/shared/utils/email'
type AddCollaboratorsSelectProps = {
readOnly?: boolean
multipleSelectionProps: ReturnType<typeof useMultipleSelection<ContactItem>>
currentMemberEmails: string[]
inputValue?: string
onInviteSuccess?: () => void
hasErrors?: boolean
}
function AddCollaboratorsSelect({
readOnly,
multipleSelectionProps,
currentMemberEmails,
inputValue,
onInviteSuccess,
hasErrors,
}: AddCollaboratorsSelectProps) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { t } = useTranslation()
const { features } = useProjectContext()
const [privileges, setPrivileges] = useState<PermissionsLevel>('readAndWrite')
const isMounted = useIsMounted()
const { setInFlight, setError } = useShareProjectContext()
const [isSubmitting, setIsSubmitting] = useState(false)
const { projectId, project, updateProject } = useProjectContext()
const { members, invites } = project || {}
const privilegeOptions = useMemo(() => {
const options: {
key: PermissionsLevel
label: string
description?: string | null
icon: AvailableUnfilledIcon
}[] = [
{
key: 'readAndWrite',
label: t('editor'),
icon: 'edit',
},
]
if (features.trackChangesVisible) {
options.push({
key: 'review',
label: t('reviewer'),
description: !features.trackChanges
? t('comment_only_upgrade_for_track_changes')
: null,
icon: 'mode_comment',
})
}
options.push({
key: 'readOnly',
label: t('viewer'),
icon: 'visibility',
})
return options
}, [features.trackChanges, features.trackChangesVisible, t])
const { reset, selectedItems } = multipleSelectionProps
useEffect(() => {
if (readOnly && privileges !== 'readOnly') {
setPrivileges('readOnly')
}
}, [privileges, readOnly])
const handleSubmit = useCallback(async () => {
if (!selectedItems.length) {
return
}
// reset the selected items
reset()
setError(undefined)
if (isSharingUpdatesEnabled) {
setIsSubmitting(true)
} else {
setInFlight(true)
}
let hasError = false
let hasInvited = false
for (const contact of selectedItems) {
// unmounting means can't add any more collaborators
if (!isMounted.current) {
break
}
const email = contact.type === 'user' ? contact.email : contact.display
const normalisedEmail = email.toLowerCase()
if (currentMemberEmails.includes(normalisedEmail)) {
continue
}
hasInvited = true
let data
try {
const invite = invites?.find(invite => invite.email === normalisedEmail)
if (invite) {
data = await resendInvite(projectId, invite)
} else {
data = await sendInvite(projectId, email, privileges)
}
const role = data?.invite?.privileges
const membersAndInvites = (members || []).concat(invites || [])
const previousEditorsAmount = membersAndInvites.filter(
member => member.privileges === 'readAndWrite'
).length
const previousReviewersAmount = membersAndInvites.filter(
member => member.privileges === 'review'
).length
const previousViewersAmount = membersAndInvites.filter(
member => member.privileges === 'readOnly'
).length
sendMB('collaborator-invited', {
project_id: projectId,
// invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
successful_invite: !!data.invite,
users_updated: !!(data.users || data.user),
current_collaborators_amount: members?.length || 0,
current_invites_amount: invites?.length || 0,
role,
previousEditorsAmount,
previousReviewersAmount,
previousViewersAmount,
newEditorsAmount:
role === 'readAndWrite'
? previousEditorsAmount + 1
: previousEditorsAmount,
newReviewersAmount:
role === 'review'
? previousReviewersAmount + 1
: previousReviewersAmount,
newViewersAmount:
role === 'readOnly'
? previousViewersAmount + 1
: previousViewersAmount,
})
} catch (error: any) {
hasError = true
if (isSharingUpdatesEnabled) {
setIsSubmitting(false)
} else {
setInFlight(false)
}
setError(
error.data?.errorReason ||
(error.response?.status === 429
? 'too_many_requests'
: 'generic_something_went_wrong')
)
break
}
if (data.error) {
hasError = true
setError(data.error)
if (isSharingUpdatesEnabled) {
setIsSubmitting(false)
} else {
setInFlight(false)
}
} else if (data.invite) {
updateProject({
invites: invites?.concat(data.invite) || [data.invite],
})
} else if (data.users) {
updateProject({
members: members?.concat(data.users) || data.users,
})
} else if (data.user) {
updateProject({
members: members?.concat(data.user) || [data.user],
})
}
// wait for a short time, so canAddCollaborators has time to update with new collaborator information
await new Promise(resolve => setTimeout(resolve, 100))
}
if (isSharingUpdatesEnabled) {
setIsSubmitting(false)
} else {
setInFlight(false)
}
if (!hasError && hasInvited) {
onInviteSuccess?.()
}
}, [
currentMemberEmails,
invites,
isMounted,
isSharingUpdatesEnabled,
members,
onInviteSuccess,
privileges,
projectId,
reset,
selectedItems,
setError,
setInFlight,
updateProject,
])
const canInvite =
!hasErrors &&
((inputValue &&
isValidEmail(inputValue) &&
!currentMemberEmails.includes(inputValue.toLowerCase())) ||
selectedItems.length > 0)
const showPermissionsSelect = Boolean(inputValue) || selectedItems.length > 0
const permissionComponent = (
<Select
dataTestId="add-collaborator-select"
items={privilegeOptions}
itemToKey={item => item.key}
itemToString={item => item?.label || ''}
itemToSubtitle={item => item?.description || ''}
itemToDisabled={item => !!(readOnly && item?.key !== 'readOnly')}
itemToLeadingIcon={item =>
isSharingUpdatesEnabled &&
item && <MaterialIcon type={item.icon} unfilled />
}
selected={privilegeOptions.find(option => option.key === privileges)}
onSelectedItemChanged={item => {
if (item) {
setPrivileges(item.key)
}
}}
selectedIcon={isSharingUpdatesEnabled}
size={isSharingUpdatesEnabled ? 'lg' : undefined}
/>
)
return (
<>
{isSharingUpdatesEnabled
? showPermissionsSelect
? permissionComponent
: null
: permissionComponent}
<ClickableElementEnhancer
as={OLButton}
onClick={handleSubmit}
variant="primary"
size={isSharingUpdatesEnabled ? 'lg' : undefined}
disabled={isSharingUpdatesEnabled && !canInvite}
isLoading={isSharingUpdatesEnabled && isSubmitting}
>
{t('invite')}
</ClickableElementEnhancer>
</>
)
}
export default AddCollaboratorsSelect

View File

@@ -1,37 +1,26 @@
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useMultipleSelection } from 'downshift'
import { useShareProjectContext } from './share-project-modal'
import SelectCollaborators, { ContactItem } from './select-collaborators'
import { resendInvite, sendInvite } from '../utils/api'
import { useUserContacts } from '../hooks/use-user-contacts'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB } from '@/infrastructure/event-tracking'
import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import { Select } from '@/shared/components/select'
import OLButton from '@/shared/components/ol/ol-button'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import OLFormText from '@/shared/components/ol/ol-form-text'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import AddCollaboratorsSelect from '@/features/share-project-modal/components/add-collaborators-select'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
const [privileges, setPrivileges] = useState<PermissionsLevel>('readAndWrite')
const isMounted = useIsMounted()
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { data: contacts } = useUserContacts()
const { t } = useTranslation()
const { setInFlight, setError } = useShareProjectContext()
const { projectId, project, features, updateProject } = useProjectContext()
const { members, invites } = project || {}
const { project } = useProjectContext()
const { members } = project || {}
const currentMemberEmails = useMemo(
() => (members || []).map(member => member.email).sort(),
() => (members || []).map(member => member.email.toLowerCase()).sort(),
[members]
)
@@ -41,7 +30,7 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
}
return contacts.filter(
contact => !currentMemberEmails.includes(contact.email)
contact => !currentMemberEmails.includes(contact.email.toLowerCase())
)
}, [contacts, currentMemberEmails])
@@ -50,201 +39,42 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
initialSelectedItems: [],
})
const { reset, selectedItems } = multipleSelectionProps
useEffect(() => {
if (readOnly && privileges !== 'readOnly') {
setPrivileges('readOnly')
}
}, [privileges, readOnly])
const handleSubmit = useCallback(async () => {
if (!selectedItems.length) {
return
}
// reset the selected items
reset()
setError(undefined)
setInFlight(true)
for (const contact of selectedItems) {
// unmounting means can't add any more collaborators
if (!isMounted.current) {
break
}
const email = contact.type === 'user' ? contact.email : contact.display
const normalisedEmail = email.toLowerCase()
if (currentMemberEmails.includes(normalisedEmail)) {
continue
}
let data
try {
const invite = invites?.find(invite => invite.email === normalisedEmail)
if (invite) {
data = await resendInvite(projectId, invite)
} else {
data = await sendInvite(projectId, email, privileges)
}
const role = data?.invite?.privileges
const membersAndInvites = (members || []).concat(invites || [])
const previousEditorsAmount = membersAndInvites.filter(
member => member.privileges === 'readAndWrite'
).length
const previousReviewersAmount = membersAndInvites.filter(
member => member.privileges === 'review'
).length
const previousViewersAmount = membersAndInvites.filter(
member => member.privileges === 'readOnly'
).length
sendMB('collaborator-invited', {
project_id: projectId,
// invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
successful_invite: !!data.invite,
users_updated: !!(data.users || data.user),
current_collaborators_amount: members?.length || 0,
current_invites_amount: invites?.length || 0,
role,
previousEditorsAmount,
previousReviewersAmount,
previousViewersAmount,
newEditorsAmount:
role === 'readAndWrite'
? previousEditorsAmount + 1
: previousEditorsAmount,
newReviewersAmount:
role === 'review'
? previousReviewersAmount + 1
: previousReviewersAmount,
newViewersAmount:
role === 'readOnly'
? previousViewersAmount + 1
: previousViewersAmount,
})
} catch (error: any) {
setInFlight(false)
setError(
error.data?.errorReason ||
(error.response?.status === 429
? 'too_many_requests'
: 'generic_something_went_wrong')
)
break
}
if (data.error) {
setError(data.error)
setInFlight(false)
} else if (data.invite) {
updateProject({
invites: invites?.concat(data.invite) || [data.invite],
})
} else if (data.users) {
updateProject({
members: members?.concat(data.users) || data.users,
})
} else if (data.user) {
updateProject({
members: members?.concat(data.user) || [data.user],
})
}
// wait for a short time, so canAddCollaborators has time to update with new collaborator information
await new Promise(resolve => setTimeout(resolve, 100))
}
setInFlight(false)
}, [
currentMemberEmails,
invites,
isMounted,
members,
privileges,
projectId,
reset,
selectedItems,
setError,
setInFlight,
updateProject,
])
const privilegeOptions = useMemo(() => {
const options: {
key: PermissionsLevel
label: string
description?: string | null
}[] = [
{
key: 'readAndWrite',
label: t('editor'),
},
]
if (features.trackChangesVisible) {
options.push({
key: 'review',
label: t('reviewer'),
description: !features.trackChanges
? t('comment_only_upgrade_for_track_changes')
: null,
})
}
options.push({
key: 'readOnly',
label: t('viewer'),
})
return options
}, [features.trackChanges, features.trackChangesVisible, t])
return (
<OLForm className="add-collabs">
<OLFormGroup>
{isSharingUpdatesEnabled ? (
<SelectCollaborators
loading={!nonMemberContacts}
options={nonMemberContacts || []}
multipleSelectionProps={multipleSelectionProps}
currentMemberEmails={currentMemberEmails}
readOnly={readOnly}
size="lg"
/>
<OLFormText id="add-collaborator-help-text">
{t('add_comma_separated_emails_help')}
</OLFormText>
</OLFormGroup>
<OLFormGroup>
<div className="float-end add-collaborator-controls">
<Select
dataTestId="add-collaborator-select"
items={privilegeOptions}
itemToKey={item => item.key}
itemToString={item => item?.label || ''}
itemToSubtitle={item => item?.description || ''}
itemToDisabled={item => !!(readOnly && item?.key !== 'readOnly')}
selected={privilegeOptions.find(
option => option.key === privileges
)}
onSelectedItemChanged={item => {
if (item) {
setPrivileges(item.key)
}
}}
/>
<ClickableElementEnhancer
as={OLButton}
onClick={handleSubmit}
variant="primary"
>
{t('invite')}
</ClickableElementEnhancer>
</div>
</OLFormGroup>
) : (
<>
<OLFormGroup>
<SelectCollaborators
loading={!nonMemberContacts}
options={nonMemberContacts || []}
multipleSelectionProps={multipleSelectionProps}
currentMemberEmails={currentMemberEmails}
readOnly={readOnly}
/>
<OLFormText id="add-collaborator-help-text">
{t('add_comma_separated_emails_help')}
</OLFormText>
</OLFormGroup>
<OLFormGroup>
<div className="float-end add-collaborator-controls add-collaborator-controls-legacy">
<AddCollaboratorsSelect
readOnly={readOnly}
multipleSelectionProps={multipleSelectionProps}
currentMemberEmails={currentMemberEmails}
/>
</div>
</OLFormGroup>
</>
)}
</OLForm>
)
}

View File

@@ -5,11 +5,17 @@ import { useCombobox, UseMultipleSelectionReturnValue } from 'downshift'
import classnames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import Tag from '@/shared/components/tag'
import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
import { Contact } from '../utils/types'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import OLFormFeedback from '@/shared/components/ol/ol-form-feedback'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLTag from '@/shared/components/ol/ol-tag'
import AddCollaboratorsSelect from '@/features/share-project-modal/components/add-collaborators-select'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { isValidEmail } from '@/shared/utils/email'
export type ContactItem = {
email: string
@@ -23,15 +29,33 @@ export type ContactItem = {
const matchAllSpaces =
/[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g
const INPUT_ERRORS = {
ALREADY_MEMBER: 'ALREADY_MEMBER',
INVALID_FORMAT: 'INVALID_FORMAT',
} as const
type InputError = {
key: `${InputError['email']}:${(typeof INPUT_ERRORS)[keyof typeof INPUT_ERRORS]}`
email: string
message: string
}
export default function SelectCollaborators({
loading,
options,
multipleSelectionProps,
currentMemberEmails,
readOnly,
size,
}: {
loading: boolean
options: Contact[]
multipleSelectionProps: UseMultipleSelectionReturnValue<ContactItem>
currentMemberEmails: string[]
readOnly?: boolean
size?: 'lg'
}) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { t } = useTranslation()
const {
getSelectedItemProps,
@@ -42,6 +66,8 @@ export default function SelectCollaborators({
} = multipleSelectionProps
const [inputValue, setInputValue] = useState('')
const [inviteSent, setInviteSent] = useState(false)
const [inputErrors, setInputErrors] = useState<InputError[]>([])
const selectedEmails = useMemo(
() => selectedItems.map(item => item.email),
@@ -146,13 +172,60 @@ export default function SelectCollaborators({
})
setInputValue('')
reset()
const inputErrorsSetter = (
inputErrors: InputError[],
errorType: (typeof INPUT_ERRORS)[keyof typeof INPUT_ERRORS],
message: InputError['message']
) => {
const key = `${email}:${errorType}` as const
return [
...inputErrors.filter(e => !(e.email === email && e.key === key)),
{
key,
email,
message,
},
]
}
// Validate email format
if (!isValidEmail(email)) {
setInputErrors(prev =>
inputErrorsSetter(
prev,
INPUT_ERRORS.INVALID_FORMAT,
t('email_must_be_a_valid_format')
)
)
}
// Validate against existing members
if (currentMemberEmails.includes(email.toLowerCase())) {
setInputErrors(prev =>
inputErrorsSetter(
prev,
INPUT_ERRORS.ALREADY_MEMBER,
t('only_add_people_who_dont_yet_have_access')
)
)
}
if (focus) {
focusInput()
}
return true
}
},
[addSelectedItem, selectedEmails, isValidInput, focusInput, reset]
[
addSelectedItem,
selectedEmails,
isValidInput,
focusInput,
reset,
currentMemberEmails,
t,
]
)
// close and reset the menu when there are no matching items
@@ -162,123 +235,197 @@ export default function SelectCollaborators({
}
}, [reset, isOpen, filteredOptions.length])
useEffect(() => {
setInputErrors(prev => prev.filter(e => selectedEmails.includes(e.email)))
}, [selectedEmails])
return (
<div className="tags-input tags-new">
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
<OLFormLabel className="small" {...getLabelProps()}>
{t('add_email_address')}
{loading && <OLSpinner size="sm" className="ms-2" />}
</OLFormLabel>
{isSharingUpdatesEnabled ? (
// eslint-disable-next-line jsx-a11y/label-has-for
<OLFormLabel {...getLabelProps()}>
{t('enter_emails_separated_by_commas')}
</OLFormLabel>
) : (
// eslint-disable-next-line jsx-a11y/label-has-for
<OLFormLabel className="small" {...getLabelProps()}>
{t('add_email_address')}
{loading && <OLSpinner size="sm" className="ms-2" />}
</OLFormLabel>
)}
<div className="host">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<div className="tags form-control" onClick={focusInput}>
{selectedItems.map((selectedItem, index) => (
<SelectedItem
key={`selected-item-${index}`}
removeSelectedItem={removeSelectedItem}
selectedItem={selectedItem}
focusInput={focusInput}
index={index}
getSelectedItemProps={getSelectedItemProps}
/>
))}
<input
data-testid="collaborator-email-input"
aria-describedby="add-collaborator-help-text"
{...getInputProps(
getDropdownProps({
className: classnames('input', {
'invalid-tag': !isValidInput,
}),
type: 'email',
size: inputValue.length ? inputValue.length + 5 : 5,
ref: inputRef,
// preventKeyAction: showDropdown,
onBlur: () => {
addNewItem(inputValue, false)
},
onChange: e => {
setInputValue((e.target as HTMLInputElement).value)
},
onClick: () => focusInput,
onKeyDown: event => {
switch (event.key) {
case 'Enter':
// Enter: always prevent form submission
event.preventDefault()
event.stopPropagation()
break
case 'Tab':
// Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful
if (!isOpen && addNewItem(inputValue)) {
event.preventDefault()
event.stopPropagation()
}
break
case ',':
// comma: try to create a new item using inputValue
event.preventDefault()
addNewItem(inputValue)
break
}
},
onPaste: event => {
const data =
// modern browsers
event.clipboardData?.getData('text/plain') ??
// @ts-ignore IE11
window.clipboardData?.getData('text')
if (data) {
const emails = data
.split(/[\r\n,; ]+/)
.filter(item => item.includes('@'))
.map(email => email.replace(matchAllSpaces, ''))
if (emails.length) {
// pasted comma-separated email addresses
event.preventDefault()
// dedupe emails in pasted content and previously-entered items
const uniqueEmails = [...new Set(emails)].filter(
email => !selectedEmails.includes(email)
)
for (const email of uniqueEmails) {
addNewItem(email)
}
}
}
},
})
)}
/>
</div>
<div>
<ul
{...getMenuProps()}
className={classnames('dropdown-menu select-dropdown-menu', {
show: isOpen,
})}
>
{isOpen &&
filteredOptions.map((item, index) => (
<Option
key={item.email}
<OLRow
className={classnames('align-items-start', {
'g-2': isSharingUpdatesEnabled,
})}
>
<OLCol>
<div className="host">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<div
className={classnames('tags form-control', {
'form-control-lg': size === 'lg',
'is-invalid':
!isValidInput ||
(isSharingUpdatesEnabled && inputErrors.length > 0),
})}
onClick={focusInput}
>
{selectedItems.map((selectedItem, index) => (
<SelectedItem
key={`selected-item-${index}`}
removeSelectedItem={removeSelectedItem}
selectedItem={selectedItem}
focusInput={focusInput}
index={index}
item={item}
selected={index === highlightedIndex}
getItemProps={getItemProps}
getSelectedItemProps={getSelectedItemProps}
hasIssue={inputErrors.some(
({ email }) => email === selectedItem.email
)}
/>
))}
</ul>
</div>
</div>
<input
data-testid="collaborator-email-input"
aria-describedby={
isSharingUpdatesEnabled
? undefined
: 'add-collaborator-help-text'
}
{...getInputProps(
getDropdownProps({
className: classnames('input', {
'invalid-tag': !isSharingUpdatesEnabled && !isValidInput,
'is-invalid': !isSharingUpdatesEnabled && !isValidInput,
}),
type: 'email',
size: inputValue.length ? inputValue.length + 5 : 5,
ref: inputRef,
// preventKeyAction: showDropdown,
onBlur: () => {
addNewItem(inputValue, false)
},
onChange: e => {
setInputValue((e.target as HTMLInputElement).value)
setInviteSent(false)
},
onClick: () => focusInput,
onKeyDown: event => {
switch (event.key) {
case 'Enter':
// Enter: always prevent form submission
event.preventDefault()
event.stopPropagation()
break
case 'Tab':
// Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful
if (!isOpen && addNewItem(inputValue)) {
event.preventDefault()
event.stopPropagation()
}
break
case ',':
// comma: try to create a new item using inputValue
event.preventDefault()
addNewItem(inputValue)
break
}
},
onPaste: event => {
const data =
// modern browsers
event.clipboardData?.getData('text/plain') ??
// @ts-ignore IE11
window.clipboardData?.getData('text')
if (data) {
const emails = data
.split(/[\r\n,; ]+/)
.filter(item => item.includes('@'))
.map(email => email.replace(matchAllSpaces, ''))
if (emails.length) {
// pasted comma-separated email addresses
event.preventDefault()
// dedupe emails in pasted content and previously-entered items
const uniqueEmails = [...new Set(emails)].filter(
email => !selectedEmails.includes(email)
)
for (const email of uniqueEmails) {
addNewItem(email)
}
}
}
},
})
)}
/>
</div>
<div>
<ul
{...getMenuProps()}
className={classnames('dropdown-menu select-dropdown-menu', {
show: isOpen,
})}
>
{isOpen &&
filteredOptions.map((item, index) => (
<Option
key={item.email}
index={index}
item={item}
selected={index === highlightedIndex}
getItemProps={getItemProps}
/>
))}
</ul>
</div>
</div>
</OLCol>
{isSharingUpdatesEnabled && (
<OLCol xs="auto">
<div className="add-collaborator-controls">
<AddCollaboratorsSelect
readOnly={readOnly}
multipleSelectionProps={multipleSelectionProps}
currentMemberEmails={currentMemberEmails}
inputValue={inputValue}
onInviteSuccess={() => setInviteSent(true)}
hasErrors={inputErrors.length > 0}
/>
</div>
</OLCol>
)}
</OLRow>
{isSharingUpdatesEnabled && (
<>
{inputErrors.length > 0 ? (
inputErrors.length === 1 ? (
<OLFormFeedback type="invalid" unfilled className="d-block">
{inputErrors[0].message}
</OLFormFeedback>
) : (
<ul className="text-danger m-0 mt-2">
{inputErrors.map(inputError => (
<li key={inputError.key} className="small p-0">
{inputError.message}
</li>
))}
</ul>
)
) : null}
{inputErrors.length === 0 && inviteSent && (
<OLFormFeedback type="valid" unfilled className="d-block">
{t('invitations_sent')}
</OLFormFeedback>
)}
</>
)}
</div>
)
}
@@ -294,12 +441,19 @@ function Option({
getItemProps: (any: any) => any
index: number
}) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
return (
<li {...getItemProps({ item, index })}>
<DropdownItem
as="span"
role={undefined}
leadingIcon="person"
leadingIcon={
isSharingUpdatesEnabled ? (
<MaterialIcon type="person" unfilled />
) : (
<MaterialIcon type="person" />
)
}
className={classnames({
active: selected,
})}
@@ -316,13 +470,16 @@ function SelectedItem({
focusInput,
getSelectedItemProps,
index,
hasIssue,
}: {
removeSelectedItem: (item: ContactItem) => void
selectedItem: ContactItem
focusInput: () => void
getSelectedItemProps: (any: any) => any
index: number
hasIssue?: boolean
}) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const handleClick = useCallback(
(event: React.MouseEvent) => {
event.preventDefault()
@@ -333,15 +490,25 @@ function SelectedItem({
[focusInput, removeSelectedItem, selectedItem]
)
let prepend: React.ReactNode
if (isSharingUpdatesEnabled) {
if (hasIssue) {
prepend = <MaterialIcon type="error" unfilled className="text-danger" />
}
} else {
prepend = <MaterialIcon type="person" />
}
return (
<Tag
prepend={<MaterialIcon type="person" />}
<OLTag
prepend={prepend}
closeBtnProps={{
onClick: handleClick,
}}
translate="no"
{...getSelectedItemProps({ selectedItem, index })}
>
{selectedItem.display}
</Tag>
</OLTag>
)
}

View File

@@ -1,21 +1,29 @@
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import Notification from '@/shared/components/notification'
import { PublicAccessLevel } from '../../../../../types/public-access-level'
import { useEditorContext } from '@/shared/context/editor-context'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLNotification from '@/shared/components/ol/ol-notification'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function SendInvitesNotice() {
function SendInvitesNotice() {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { project } = useProjectContext()
const { publicAccessLevel } = project || {}
const { isPendingEditor } = useEditorContext()
const { t } = useTranslation()
let accessLevelText = ''
if (publicAccessLevel === 'private') {
accessLevelText = t('to_add_more_collaborators')
} else if (publicAccessLevel === 'tokenBased') {
accessLevelText = t('to_change_access_permissions')
}
return (
<div>
{isPendingEditor && (
<Notification
<OLNotification
isActionBelowContent
type="info"
title={t('youve_lost_collaboration_access')}
@@ -31,29 +39,18 @@ export default function SendInvitesNotice() {
}
/>
)}
<OLRow className="public-access-level public-access-level-notice">
<OLCol className="text-center">
<AccessLevel level={publicAccessLevel} />
</OLCol>
</OLRow>
{accessLevelText &&
(isSharingUpdatesEnabled ? (
<OLNotification type="info" content={accessLevelText} />
) : (
<OLRow className="public-access-level public-access-level-notice">
<OLCol className="text-center">
<span>{accessLevelText}</span>
</OLCol>
</OLRow>
))}
</div>
)
}
type AccessLevelProps = {
level: PublicAccessLevel | undefined
}
function AccessLevel({ level }: AccessLevelProps) {
const { t } = useTranslation()
switch (level) {
case 'private':
return <span>{t('to_add_more_collaborators')}</span>
case 'tokenBased':
return <span>{t('to_change_access_permissions')}</span>
default:
return <span>''</span>
}
}
export default SendInvitesNotice

View File

@@ -19,10 +19,6 @@ export default function SendInvites({
}) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
if (isSharingUpdatesEnabled) {
return null
}
return (
<OLRow
className={classnames('invite-controls', {

View File

@@ -5,12 +5,15 @@ import { ComponentProps } from 'react'
export type FormFeedbackProps = Pick<
ComponentProps<typeof Form.Control.Feedback>,
'type' | 'className' | 'children'
>
> & { unfilled?: boolean }
function FormFeedback(props: FormFeedbackProps) {
function FormFeedback({ unfilled, ...props }: FormFeedbackProps) {
return (
<Form.Control.Feedback {...props}>
<FormText type={props.type === 'invalid' ? 'error' : 'success'}>
<FormText
type={props.type === 'invalid' ? 'error' : 'success'}
unfilled={unfilled}
>
{props.children}
</FormText>
</Form.Control.Feedback>

View File

@@ -9,6 +9,7 @@ export type FormTextProps = MergeAndOverride<
BS5FormTextProps,
{
type?: TextType
unfilled?: boolean
marginless?: boolean
}
>
@@ -22,16 +23,35 @@ const typeClassMap: Partial<Record<TextType, string>> = {
export const getFormTextClass = (type?: TextType) =>
typeClassMap[type || 'default']
function FormTextIcon({ type }: { type?: TextType }) {
function FormTextIcon({
type,
unfilled,
}: Pick<FormTextProps, 'type' | 'unfilled'>) {
switch (type) {
case 'info':
return <MaterialIcon type="info" className="text-info" />
return unfilled ? (
<MaterialIcon type="info" className="text-info" unfilled />
) : (
<MaterialIcon type="info" className="text-info" />
)
case 'success':
return <MaterialIcon type="check_circle" />
return unfilled ? (
<MaterialIcon type="check_circle" unfilled />
) : (
<MaterialIcon type="check_circle" />
)
case 'warning':
return <MaterialIcon type="warning" />
return unfilled ? (
<MaterialIcon type="warning" unfilled />
) : (
<MaterialIcon type="warning" />
)
case 'error':
return <MaterialIcon type="error" />
return unfilled ? (
<MaterialIcon type="error" unfilled />
) : (
<MaterialIcon type="error" />
)
default:
return null
}
@@ -42,6 +62,7 @@ function FormText({
marginless,
children,
className,
unfilled,
...rest
}: FormTextProps) {
return (
@@ -50,7 +71,7 @@ function FormText({
{...rest}
>
<span className="form-text-inner">
<FormTextIcon type={type} />
<FormTextIcon type={type} unfilled={unfilled} />
<span>{children}</span>
</span>
</Form.Text>

View File

@@ -1,13 +1,8 @@
import { Form } from 'react-bootstrap'
import { ComponentProps } from 'react'
import FormFeedback from '@/shared/components/form/form-feedback'
import FormFeedback, {
FormFeedbackProps,
} from '@/shared/components/form/form-feedback'
type OLFormFeedbackProps = Pick<
ComponentProps<typeof Form.Control.Feedback>,
'type' | 'className' | 'children'
>
function OLFormFeedback(props: OLFormFeedbackProps) {
function OLFormFeedback(props: FormFeedbackProps) {
return <FormFeedback {...props} />
}

View File

@@ -18,6 +18,7 @@ import OLSpinner from './ol/ol-spinner'
import DSFormLabel from '@/shared/components/ds/ds-form-label'
import DSFormGroup from '@/shared/components/ds/ds-form-group'
import DSFormControl from '@/shared/components/ds/ds-form-control'
import { DropdownItemProps } from '@/shared/components/types/dropdown-menu-props'
export type SelectProps<T> = {
// The items rendered as dropdown options.
@@ -39,6 +40,10 @@ export type SelectProps<T> = {
itemToSubtitle?: (item: T | null | undefined) => string
// Stringifies an item. The resulting string is rendered as a React `key` for each item.
itemToKey: (item: T) => string
// Maps an item to a leading icon.
itemToLeadingIcon?: (
item: T | null | undefined
) => DropdownItemProps['leadingIcon']
// Callback invoked after the selected item is updated.
onSelectedItemChanged?: (item: T | null | undefined) => void
// Optionally directly control the selected item.
@@ -57,6 +62,7 @@ export type SelectProps<T> = {
dataTestId?: string
// CIAM-specific layout
isCiam?: boolean
size?: React.ComponentProps<typeof FormControl>['size']
}
export const Select = <T,>({
@@ -68,6 +74,7 @@ export const Select = <T,>({
defaultItem,
itemToSubtitle,
itemToKey,
itemToLeadingIcon,
onSelectedItemChanged,
selected,
disabled = false,
@@ -77,6 +84,7 @@ export const Select = <T,>({
selectedIcon = false,
dataTestId,
isCiam,
size,
}: SelectProps<T>) => {
const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
defaultItem
@@ -185,6 +193,9 @@ export const Select = <T,>({
trailingIcon={
selectedIcon && selectedItem === item ? tickIcon() : undefined
}
leadingIcon={
itemToLeadingIcon ? itemToLeadingIcon(item) : undefined
}
description={itemToSubtitle ? itemToSubtitle(item) : undefined}
{...itemProps}
disabled={disabled}
@@ -250,6 +261,7 @@ export const Select = <T,>({
className="align-text-bottom"
/>
}
size={size}
/>
{dropdown}
</div>

View File

@@ -37,6 +37,21 @@
}
}
}
&.form-control-lg {
min-height: initial;
padding-top: calc(#{$input-padding-y-lg} - #{$input-border-width} * 2);
padding-bottom: calc(#{$input-padding-y-lg} - #{$input-border-width} * 2);
}
}
.modal-redesign {
.tags-input .tags {
.badge-tag {
margin-top: 1px;
margin-bottom: 1px;
}
}
}
.tags-input .tags:focus-within {

View File

@@ -755,6 +755,7 @@
"email_does_not_belong_to_university": "We dont recognize that domain as being affiliated with your university. Please contact us to add the affiliation.",
"email_limit_reached": "You can have a maximum of <0>__emailAddressLimit__ email addresses</0> on this account. To add another email address, please delete an existing one.",
"email_link_expired": "Email link expired, please request a new one.",
"email_must_be_a_valid_format": "Email addresses must be a valid format.",
"email_must_be_linked_to_institution": "As a member of __institutionName__, this email address can only be added via single sign-on on your <0>account settings</0> page. Please add a different recovery email address.",
"email_notifications_are_currently_in_beta": "Email notifications are currently in beta.",
"email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.",
@@ -781,6 +782,7 @@
"end_time_utc": "End time (UTC)",
"ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.",
"enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
"enter_emails_separated_by_commas": "Enter emails separated by commas",
"enter_manually": "Enter manually",
"enter_tax_id_number": "Enter tax ID number",
"enter_the_code": "Enter the 6-digit code sent to __email__.",
@@ -1257,6 +1259,7 @@
"invalid_zip_file": "Invalid zip file",
"invert_pdf_preview_colors": "Invert PDF preview colors",
"invert_pdf_preview_colors_when_in_dark_mode": "Invert PDF preview colors when in dark mode",
"invitations_sent": "Invitation(s) sent.",
"invite": "Invite",
"invite_expired": "The invite may have expired",
"invite_group_members": "Invite group members",
@@ -1694,6 +1697,7 @@
"one_user": "1 user",
"ongoing_experiments": "Ongoing experiments",
"online_latex_editor": "Online LaTeX Editor",
"only_add_people_who_dont_yet_have_access": "Only add people who dont yet have access.",
"only_group_admin_or_managers_can_delete_your_account_1": "By becoming a managed user, your organization will have admin rights over your account and control over your stuff, including the right to close your account and access, delete and share your stuff. As a result:",
"only_group_admin_or_managers_can_delete_your_account_10": "If you have an individual subscription, well automatically terminate it and cancel its renewal when your account becomes managed. To request a pro-rata refund for the remainder, please contact Support.",
"only_group_admin_or_managers_can_delete_your_account_3": "Your group admin and group managers will be able to reassign ownership of your projects to another group member.",

View File

@@ -1052,4 +1052,127 @@ describe('<ShareProjectModal/>', function () {
)
})
})
describe('with "sharing-updates" feature flag', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'sharing-updates': 'enabled',
})
})
afterEach(function () {
window.metaAttributesCache.set('ol-splitTestVariants', {})
})
it('disables the invite button when no email is entered', async function () {
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased' })
)
const inviteButton = (await screen.findByRole('button', {
name: /invite/i,
})) as HTMLButtonElement
expect(inviteButton.disabled).to.be.true
})
it('enables the invite button once a valid email is typed', async function () {
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased' })
)
const inviteButton = (await screen.findByRole('button', {
name: /invite/i,
})) as HTMLButtonElement
expect(inviteButton.disabled).to.be.true
const inputElement = await screen.findByTestId('collaborator-email-input')
fireEvent.change(inputElement, {
target: { value: 'new@example.com' },
})
await waitFor(() => expect(inviteButton.disabled).to.be.false)
})
it('shows a validation error and disables invite button for an invalid email format', async function () {
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased' })
)
const inputElement = await screen.findByTestId('collaborator-email-input')
fireEvent.change(inputElement, { target: { value: 'invalid@' } })
fireEvent.blur(inputElement)
await screen.findByText('Email addresses must be a valid format.')
const inviteButton = screen.getByRole('button', {
name: /invite/i,
}) as HTMLButtonElement
expect(inviteButton.disabled).to.be.true
})
it('shows a validation error and disables invite button when email already has access', async function () {
const members: ProjectMember[] = [
{
_id: 'existing-member' as UserId,
email: 'member@example.com',
privileges: 'readAndWrite',
first_name: 'Existing',
last_name: 'Member',
},
]
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased', members })
)
const inputElement = await screen.findByTestId('collaborator-email-input')
fireEvent.change(inputElement, {
target: { value: 'member@example.com' },
})
fireEvent.blur(inputElement)
await screen.findByText('Only add people who dont yet have access.')
const inviteButton = screen.getByRole('button', {
name: /invite/i,
}) as HTMLButtonElement
expect(inviteButton.disabled).to.be.true
})
it('shows "invitations sent" message after a successful invite', async function () {
fetchMock.post('express:/project/:projectId/invite', {
status: 200,
body: {
invite: {
_id: 'new-invite',
email: 'new@example.com',
privileges: 'readAndWrite',
},
},
})
renderWithEditorContext(
<ShareProjectModal {...modalProps} />,
createContextProps({ publicAccessLevel: 'tokenBased' })
)
const inputElement = await screen.findByTestId('collaborator-email-input')
fireEvent.change(inputElement, { target: { value: 'new@example.com' } })
fireEvent.blur(inputElement)
const inviteButton = (await screen.findByRole('button', {
name: /invite/i,
})) as HTMLButtonElement
await waitFor(() => expect(inviteButton.disabled).to.be.false)
await userEvent.click(inviteButton)
await screen.findByText('Invitation(s) sent.')
})
})
})