| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- 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
- }
- 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) {
- hasInvited = true
- updateProject({
- invites: invites?.concat(data.invite) || [data.invite],
- })
- } else if (data.users) {
- hasInvited = true
- updateProject({
- members: members?.concat(data.users) || data.users,
- })
- } else if (data.user) {
- hasInvited = true
- updateProject({
- members: members?.concat(data.user) || [data.user],
- })
- } else if (!('invite' in data)) {
- // a successful resend returns an empty body (no `invite` field)
- hasInvited = true
- } else {
- hasError = true
- setError('generic_something_went_wrong')
- if (isSharingUpdatesEnabled) {
- setIsSubmitting(false)
- } else {
- setInFlight(false)
- }
- }
- // 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
|