mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
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:
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,10 +19,6 @@ export default function SendInvites({
|
||||
}) {
|
||||
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
|
||||
|
||||
if (isSharingUpdatesEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<OLRow
|
||||
className={classnames('invite-controls', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -755,6 +755,7 @@
|
||||
"email_does_not_belong_to_university": "We don’t 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 don’t 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, we’ll 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.",
|
||||
|
||||
@@ -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 don’t 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.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user