add-collaborators.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import { useEffect, useState, useMemo, useCallback } from 'react'
  2. import { useTranslation } from 'react-i18next'
  3. import { useMultipleSelection } from 'downshift'
  4. import { useShareProjectContext } from './share-project-modal'
  5. import SelectCollaborators, { ContactItem } from './select-collaborators'
  6. import { resendInvite, sendInvite } from '../utils/api'
  7. import { useUserContacts } from '../hooks/use-user-contacts'
  8. import useIsMounted from '@/shared/hooks/use-is-mounted'
  9. import { useProjectContext } from '@/shared/context/project-context'
  10. import { sendMB } from '@/infrastructure/event-tracking'
  11. import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
  12. import OLForm from '@/features/ui/components/ol/ol-form'
  13. import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
  14. import { Select } from '@/shared/components/select'
  15. import OLButton from '@/features/ui/components/ol/ol-button'
  16. export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
  17. const [privileges, setPrivileges] = useState('readAndWrite')
  18. const isMounted = useIsMounted()
  19. const { data: contacts } = useUserContacts()
  20. const { t } = useTranslation()
  21. const { setInFlight, setError } = useShareProjectContext()
  22. const { projectId, project, features, updateProject } = useProjectContext()
  23. const { members, invites } = project || {}
  24. const currentMemberEmails = useMemo(
  25. () => (members || []).map(member => member.email).sort(),
  26. [members]
  27. )
  28. const nonMemberContacts = useMemo(() => {
  29. if (!contacts) {
  30. return null
  31. }
  32. return contacts.filter(
  33. contact => !currentMemberEmails.includes(contact.email)
  34. )
  35. }, [contacts, currentMemberEmails])
  36. const multipleSelectionProps = useMultipleSelection<ContactItem>({
  37. initialActiveIndex: 0,
  38. initialSelectedItems: [],
  39. })
  40. const { reset, selectedItems } = multipleSelectionProps
  41. useEffect(() => {
  42. if (readOnly && privileges !== 'readOnly') {
  43. setPrivileges('readOnly')
  44. }
  45. }, [privileges, readOnly])
  46. const handleSubmit = useCallback(async () => {
  47. if (!selectedItems.length) {
  48. return
  49. }
  50. // reset the selected items
  51. reset()
  52. setError(undefined)
  53. setInFlight(true)
  54. for (const contact of selectedItems) {
  55. // unmounting means can't add any more collaborators
  56. if (!isMounted.current) {
  57. break
  58. }
  59. const email = contact.type === 'user' ? contact.email : contact.display
  60. const normalisedEmail = email.toLowerCase()
  61. if (currentMemberEmails.includes(normalisedEmail)) {
  62. continue
  63. }
  64. let data
  65. try {
  66. const invite = invites?.find(invite => invite.email === normalisedEmail)
  67. if (invite) {
  68. data = await resendInvite(projectId, invite)
  69. } else {
  70. data = await sendInvite(projectId, email, privileges)
  71. }
  72. const role = data?.invite?.privileges
  73. const membersAndInvites = (members || []).concat(invites || [])
  74. const previousEditorsAmount = membersAndInvites.filter(
  75. member => member.privileges === 'readAndWrite'
  76. ).length
  77. const previousReviewersAmount = membersAndInvites.filter(
  78. member => member.privileges === 'review'
  79. ).length
  80. const previousViewersAmount = membersAndInvites.filter(
  81. member => member.privileges === 'readOnly'
  82. ).length
  83. sendMB('collaborator-invited', {
  84. project_id: projectId,
  85. // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
  86. successful_invite: !!data.invite,
  87. users_updated: !!(data.users || data.user),
  88. current_collaborators_amount: members?.length || 0,
  89. current_invites_amount: invites?.length || 0,
  90. role,
  91. previousEditorsAmount,
  92. previousReviewersAmount,
  93. previousViewersAmount,
  94. newEditorsAmount:
  95. role === 'readAndWrite'
  96. ? previousEditorsAmount + 1
  97. : previousEditorsAmount,
  98. newReviewersAmount:
  99. role === 'review'
  100. ? previousReviewersAmount + 1
  101. : previousReviewersAmount,
  102. newViewersAmount:
  103. role === 'readOnly'
  104. ? previousViewersAmount + 1
  105. : previousViewersAmount,
  106. })
  107. } catch (error: any) {
  108. setInFlight(false)
  109. setError(
  110. error.data?.errorReason ||
  111. (error.response?.status === 429
  112. ? 'too_many_requests'
  113. : 'generic_something_went_wrong')
  114. )
  115. break
  116. }
  117. if (data.error) {
  118. setError(data.error)
  119. setInFlight(false)
  120. } else if (data.invite) {
  121. updateProject({
  122. invites: invites?.concat(data.invite) || [data.invite],
  123. })
  124. } else if (data.users) {
  125. updateProject({
  126. members: members?.concat(data.users) || data.users,
  127. })
  128. } else if (data.user) {
  129. updateProject({
  130. members: members?.concat(data.user) || [data.user],
  131. })
  132. }
  133. // wait for a short time, so canAddCollaborators has time to update with new collaborator information
  134. await new Promise(resolve => setTimeout(resolve, 100))
  135. }
  136. setInFlight(false)
  137. }, [
  138. currentMemberEmails,
  139. invites,
  140. isMounted,
  141. members,
  142. privileges,
  143. projectId,
  144. reset,
  145. selectedItems,
  146. setError,
  147. setInFlight,
  148. updateProject,
  149. ])
  150. const privilegeOptions = useMemo(() => {
  151. const options: {
  152. key: string
  153. label: string
  154. description?: string | null
  155. }[] = [
  156. {
  157. key: 'readAndWrite',
  158. label: t('editor'),
  159. },
  160. ]
  161. if (features.trackChangesVisible) {
  162. options.push({
  163. key: 'review',
  164. label: t('reviewer'),
  165. description: !features.trackChanges
  166. ? t('comment_only_upgrade_for_track_changes')
  167. : null,
  168. })
  169. }
  170. options.push({
  171. key: 'readOnly',
  172. label: t('viewer'),
  173. })
  174. return options
  175. }, [features.trackChanges, features.trackChangesVisible, t])
  176. return (
  177. <OLForm className="add-collabs">
  178. <OLFormGroup>
  179. <SelectCollaborators
  180. loading={!nonMemberContacts}
  181. options={nonMemberContacts || []}
  182. placeholder="Email, comma separated"
  183. multipleSelectionProps={multipleSelectionProps}
  184. />
  185. </OLFormGroup>
  186. <OLFormGroup>
  187. <div className="pull-right add-collaborator-controls">
  188. <Select
  189. dataTestId="add-collaborator-select"
  190. items={privilegeOptions}
  191. itemToKey={item => item.key}
  192. itemToString={item => item?.label || ''}
  193. itemToSubtitle={item => item?.description || ''}
  194. itemToDisabled={item => !!(readOnly && item?.key !== 'readOnly')}
  195. selected={privilegeOptions.find(
  196. option => option.key === privileges
  197. )}
  198. onSelectedItemChanged={item => {
  199. if (item) {
  200. setPrivileges(item.key)
  201. }
  202. }}
  203. />
  204. <ClickableElementEnhancer
  205. as={OLButton}
  206. onClick={handleSubmit}
  207. variant="primary"
  208. >
  209. {t('invite')}
  210. </ClickableElementEnhancer>
  211. </div>
  212. </OLFormGroup>
  213. </OLForm>
  214. )
  215. }