add-collaborators-select.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import { useMemo, useCallback, useEffect, useState } from 'react'
  2. import { useTranslation } from 'react-i18next'
  3. import { useMultipleSelection } from 'downshift'
  4. import { Select } from '@/shared/components/select'
  5. import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
  6. import OLButton from '@/shared/components/ol/ol-button'
  7. import { PermissionsLevel } from '@/features/ide-react/types/permissions'
  8. import { useProjectContext } from '@/shared/context/project-context'
  9. import {
  10. resendInvite,
  11. sendInvite,
  12. } from '@/features/share-project-modal/utils/api'
  13. import { ContactItem } from '@/features/share-project-modal/components/select-collaborators'
  14. import { useShareProjectContext } from '@/features/share-project-modal/components/share-project-modal'
  15. import useIsMounted from '@/shared/hooks/use-is-mounted'
  16. import { useFeatureFlag } from '@/shared/context/split-test-context'
  17. import { sendMB } from '@/infrastructure/event-tracking'
  18. import MaterialIcon, {
  19. AvailableUnfilledIcon,
  20. } from '@/shared/components/material-icon'
  21. import { isValidEmail } from '@/shared/utils/email'
  22. type AddCollaboratorsSelectProps = {
  23. readOnly?: boolean
  24. multipleSelectionProps: ReturnType<typeof useMultipleSelection<ContactItem>>
  25. currentMemberEmails: string[]
  26. inputValue?: string
  27. onInviteSuccess?: () => void
  28. hasErrors?: boolean
  29. }
  30. function AddCollaboratorsSelect({
  31. readOnly,
  32. multipleSelectionProps,
  33. currentMemberEmails,
  34. inputValue,
  35. onInviteSuccess,
  36. hasErrors,
  37. }: AddCollaboratorsSelectProps) {
  38. const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
  39. const { t } = useTranslation()
  40. const { features } = useProjectContext()
  41. const [privileges, setPrivileges] = useState<PermissionsLevel>('readAndWrite')
  42. const isMounted = useIsMounted()
  43. const { setInFlight, setError } = useShareProjectContext()
  44. const [isSubmitting, setIsSubmitting] = useState(false)
  45. const { projectId, project, updateProject } = useProjectContext()
  46. const { members, invites } = project || {}
  47. const privilegeOptions = useMemo(() => {
  48. const options: {
  49. key: PermissionsLevel
  50. label: string
  51. description?: string | null
  52. icon: AvailableUnfilledIcon
  53. }[] = [
  54. {
  55. key: 'readAndWrite',
  56. label: t('editor'),
  57. icon: 'edit',
  58. },
  59. ]
  60. if (features.trackChangesVisible) {
  61. options.push({
  62. key: 'review',
  63. label: t('reviewer'),
  64. description: !features.trackChanges
  65. ? t('comment_only_upgrade_for_track_changes')
  66. : null,
  67. icon: 'mode_comment',
  68. })
  69. }
  70. options.push({
  71. key: 'readOnly',
  72. label: t('viewer'),
  73. icon: 'visibility',
  74. })
  75. return options
  76. }, [features.trackChanges, features.trackChangesVisible, t])
  77. const { reset, selectedItems } = multipleSelectionProps
  78. useEffect(() => {
  79. if (readOnly && privileges !== 'readOnly') {
  80. setPrivileges('readOnly')
  81. }
  82. }, [privileges, readOnly])
  83. const handleSubmit = useCallback(async () => {
  84. if (!selectedItems.length) {
  85. return
  86. }
  87. // reset the selected items
  88. reset()
  89. setError(undefined)
  90. if (isSharingUpdatesEnabled) {
  91. setIsSubmitting(true)
  92. } else {
  93. setInFlight(true)
  94. }
  95. let hasError = false
  96. let hasInvited = false
  97. for (const contact of selectedItems) {
  98. // unmounting means can't add any more collaborators
  99. if (!isMounted.current) {
  100. break
  101. }
  102. const email = contact.type === 'user' ? contact.email : contact.display
  103. const normalisedEmail = email.toLowerCase()
  104. if (currentMemberEmails.includes(normalisedEmail)) {
  105. continue
  106. }
  107. let data
  108. try {
  109. const invite = invites?.find(invite => invite.email === normalisedEmail)
  110. if (invite) {
  111. data = await resendInvite(projectId, invite)
  112. } else {
  113. data = await sendInvite(projectId, email, privileges)
  114. }
  115. const role = data?.invite?.privileges
  116. const membersAndInvites = (members || []).concat(invites || [])
  117. const previousEditorsAmount = membersAndInvites.filter(
  118. member => member.privileges === 'readAndWrite'
  119. ).length
  120. const previousReviewersAmount = membersAndInvites.filter(
  121. member => member.privileges === 'review'
  122. ).length
  123. const previousViewersAmount = membersAndInvites.filter(
  124. member => member.privileges === 'readOnly'
  125. ).length
  126. sendMB('collaborator-invited', {
  127. project_id: projectId,
  128. // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
  129. successful_invite: !!data.invite,
  130. users_updated: !!(data.users || data.user),
  131. current_collaborators_amount: members?.length || 0,
  132. current_invites_amount: invites?.length || 0,
  133. role,
  134. previousEditorsAmount,
  135. previousReviewersAmount,
  136. previousViewersAmount,
  137. newEditorsAmount:
  138. role === 'readAndWrite'
  139. ? previousEditorsAmount + 1
  140. : previousEditorsAmount,
  141. newReviewersAmount:
  142. role === 'review'
  143. ? previousReviewersAmount + 1
  144. : previousReviewersAmount,
  145. newViewersAmount:
  146. role === 'readOnly'
  147. ? previousViewersAmount + 1
  148. : previousViewersAmount,
  149. })
  150. } catch (error: any) {
  151. hasError = true
  152. if (isSharingUpdatesEnabled) {
  153. setIsSubmitting(false)
  154. } else {
  155. setInFlight(false)
  156. }
  157. setError(
  158. error.data?.errorReason ||
  159. (error.response?.status === 429
  160. ? 'too_many_requests'
  161. : 'generic_something_went_wrong')
  162. )
  163. break
  164. }
  165. if (data.error) {
  166. hasError = true
  167. setError(data.error)
  168. if (isSharingUpdatesEnabled) {
  169. setIsSubmitting(false)
  170. } else {
  171. setInFlight(false)
  172. }
  173. } else if (data.invite) {
  174. hasInvited = true
  175. updateProject({
  176. invites: invites?.concat(data.invite) || [data.invite],
  177. })
  178. } else if (data.users) {
  179. hasInvited = true
  180. updateProject({
  181. members: members?.concat(data.users) || data.users,
  182. })
  183. } else if (data.user) {
  184. hasInvited = true
  185. updateProject({
  186. members: members?.concat(data.user) || [data.user],
  187. })
  188. } else if (!('invite' in data)) {
  189. // a successful resend returns an empty body (no `invite` field)
  190. hasInvited = true
  191. } else {
  192. hasError = true
  193. setError('generic_something_went_wrong')
  194. if (isSharingUpdatesEnabled) {
  195. setIsSubmitting(false)
  196. } else {
  197. setInFlight(false)
  198. }
  199. }
  200. // wait for a short time, so canAddCollaborators has time to update with new collaborator information
  201. await new Promise(resolve => setTimeout(resolve, 100))
  202. }
  203. if (isSharingUpdatesEnabled) {
  204. setIsSubmitting(false)
  205. } else {
  206. setInFlight(false)
  207. }
  208. if (!hasError && hasInvited) {
  209. onInviteSuccess?.()
  210. }
  211. }, [
  212. currentMemberEmails,
  213. invites,
  214. isMounted,
  215. isSharingUpdatesEnabled,
  216. members,
  217. onInviteSuccess,
  218. privileges,
  219. projectId,
  220. reset,
  221. selectedItems,
  222. setError,
  223. setInFlight,
  224. updateProject,
  225. ])
  226. const canInvite =
  227. !hasErrors &&
  228. ((inputValue &&
  229. isValidEmail(inputValue) &&
  230. !currentMemberEmails.includes(inputValue.toLowerCase())) ||
  231. selectedItems.length > 0)
  232. const showPermissionsSelect = Boolean(inputValue) || selectedItems.length > 0
  233. const permissionComponent = (
  234. <Select
  235. dataTestId="add-collaborator-select"
  236. items={privilegeOptions}
  237. itemToKey={item => item.key}
  238. itemToString={item => item?.label || ''}
  239. itemToSubtitle={item => item?.description || ''}
  240. itemToDisabled={item => !!(readOnly && item?.key !== 'readOnly')}
  241. itemToLeadingIcon={item =>
  242. isSharingUpdatesEnabled &&
  243. item && <MaterialIcon type={item.icon} unfilled />
  244. }
  245. selected={privilegeOptions.find(option => option.key === privileges)}
  246. onSelectedItemChanged={item => {
  247. if (item) {
  248. setPrivileges(item.key)
  249. }
  250. }}
  251. selectedIcon={isSharingUpdatesEnabled}
  252. size={isSharingUpdatesEnabled ? 'lg' : undefined}
  253. />
  254. )
  255. return (
  256. <>
  257. {isSharingUpdatesEnabled
  258. ? showPermissionsSelect
  259. ? permissionComponent
  260. : null
  261. : permissionComponent}
  262. <ClickableElementEnhancer
  263. as={OLButton}
  264. onClick={handleSubmit}
  265. variant="primary"
  266. size={isSharingUpdatesEnabled ? 'lg' : undefined}
  267. disabled={isSharingUpdatesEnabled && !canInvite}
  268. isLoading={isSharingUpdatesEnabled && isSubmitting}
  269. >
  270. {t('invite')}
  271. </ClickableElementEnhancer>
  272. </>
  273. )
  274. }
  275. export default AddCollaboratorsSelect