managers-table.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import { useCallback, useState } from 'react'
  2. import { Button, Col, Form, FormControl, Row } from 'react-bootstrap'
  3. import { useTranslation } from 'react-i18next'
  4. import {
  5. deleteJSON,
  6. FetchError,
  7. postJSON,
  8. } from '../../../infrastructure/fetch-json'
  9. import MaterialIcon from '../../../shared/components/material-icon'
  10. import Tooltip from '../../../shared/components/tooltip'
  11. import getMeta from '../../../utils/meta'
  12. import { parseEmails } from '../utils/emails'
  13. import ErrorAlert, { APIError } from './error-alert'
  14. import GroupMemberRow from './group-member-row'
  15. import useUserSelection from '../hooks/use-user-selection'
  16. import { User } from '../../../../../types/group-management/user'
  17. import { debugConsole } from '@/utils/debugging'
  18. type ManagersPaths = {
  19. addMember: string
  20. removeMember: string
  21. }
  22. type UsersTableProps = {
  23. groupName: string
  24. paths: ManagersPaths
  25. translations: {
  26. title: string
  27. subtitle: string
  28. remove: string
  29. }
  30. }
  31. export function ManagersTable({
  32. groupName,
  33. translations,
  34. paths,
  35. }: UsersTableProps) {
  36. const { t } = useTranslation()
  37. const {
  38. users,
  39. setUsers,
  40. selectedUsers,
  41. selectAllUsers,
  42. unselectAllUsers,
  43. selectUser,
  44. unselectUser,
  45. } = useUserSelection(getMeta('ol-users', []))
  46. const [emailString, setEmailString] = useState<string>('')
  47. const [inviteUserInflightCount, setInviteUserInflightCount] = useState(0)
  48. const [inviteError, setInviteError] = useState<APIError>()
  49. const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
  50. const [removeMemberError, setRemoveMemberError] = useState<APIError>()
  51. const addManagers = useCallback(
  52. e => {
  53. e.preventDefault()
  54. setInviteError(undefined)
  55. const emails = parseEmails(emailString)
  56. ;(async () => {
  57. for (const email of emails) {
  58. setInviteUserInflightCount(count => count + 1)
  59. try {
  60. const data = await postJSON<{ user: User }>(paths.addMember, {
  61. body: {
  62. email,
  63. },
  64. })
  65. if (data.user) {
  66. const alreadyListed = users.find(
  67. user => user.email === data.user.email
  68. )
  69. if (!alreadyListed) {
  70. setUsers(users => [...users, data.user])
  71. }
  72. }
  73. setEmailString('')
  74. } catch (error: unknown) {
  75. debugConsole.error(error)
  76. setInviteError((error as FetchError)?.data?.error || {})
  77. }
  78. setInviteUserInflightCount(count => count - 1)
  79. }
  80. })()
  81. },
  82. [emailString, paths.addMember, users, setUsers]
  83. )
  84. const removeManagers = useCallback(
  85. e => {
  86. e.preventDefault()
  87. setRemoveMemberError(undefined)
  88. ;(async () => {
  89. for (const user of selectedUsers) {
  90. let url
  91. if (paths.removeMember && user._id) {
  92. url = `${paths.removeMember}/${user._id}`
  93. } else {
  94. return
  95. }
  96. setRemoveMemberInflightCount(count => count + 1)
  97. try {
  98. await deleteJSON(url, {})
  99. setUsers(users => users.filter(u => u !== user))
  100. unselectUser(user)
  101. } catch (error: unknown) {
  102. debugConsole.error(error)
  103. setRemoveMemberError((error as FetchError)?.data?.error || {})
  104. }
  105. setRemoveMemberInflightCount(count => count - 1)
  106. }
  107. })()
  108. },
  109. [selectedUsers, unselectUser, setUsers, paths.removeMember]
  110. )
  111. const handleSelectAllClick = useCallback(
  112. (e: React.ChangeEvent<HTMLInputElement>) => {
  113. if (e.target.checked) {
  114. selectAllUsers()
  115. } else {
  116. unselectAllUsers()
  117. }
  118. },
  119. [selectAllUsers, unselectAllUsers]
  120. )
  121. const handleEmailsChange = useCallback(
  122. e => {
  123. setEmailString(e.target.value)
  124. },
  125. [setEmailString]
  126. )
  127. return (
  128. <div className="container">
  129. <Row>
  130. <Col md={10} mdOffset={1}>
  131. <h1>
  132. <a href="/user/subscription" className="back-btn">
  133. <MaterialIcon
  134. type="arrow_back"
  135. accessibilityLabel={t('back_to_subscription')}
  136. />
  137. </a>{' '}
  138. {groupName || translations.title}
  139. </h1>
  140. <div className="card">
  141. <div className="page-header">
  142. <div className="pull-right">
  143. {removeMemberInflightCount > 0 ? (
  144. <Button bsStyle="danger" disabled>
  145. {t('removing')}&hellip;
  146. </Button>
  147. ) : (
  148. <>
  149. {selectedUsers.length > 0 && (
  150. <Button bsStyle="danger" onClick={removeManagers}>
  151. {translations.remove}
  152. </Button>
  153. )}
  154. </>
  155. )}
  156. </div>
  157. <h3>{translations.subtitle}</h3>
  158. </div>
  159. <div className="row-spaced-small">
  160. <ErrorAlert error={removeMemberError} />
  161. <ul className="list-unstyled structured-list">
  162. <li className="container-fluid">
  163. <Row>
  164. <Col xs={4}>
  165. <label htmlFor="select-all" className="sr-only">
  166. {t('select_all')}
  167. </label>
  168. <input
  169. className="select-all"
  170. id="select-all"
  171. type="checkbox"
  172. onChange={handleSelectAllClick}
  173. checked={selectedUsers.length === users.length}
  174. />
  175. <span className="header">{t('email')}</span>
  176. </Col>
  177. <Col xs={4}>
  178. <span className="header">{t('name')}</span>
  179. </Col>
  180. <Col xs={2}>
  181. <Tooltip
  182. id="last-active-tooltip"
  183. description={t('last_active_description')}
  184. overlayProps={{
  185. placement: 'left',
  186. }}
  187. >
  188. <span className="header">
  189. {t('last_active')}
  190. <sup>(?)</sup>
  191. </span>
  192. </Tooltip>
  193. </Col>
  194. <Col xs={2}>
  195. <span className="header">{t('accepted_invite')}</span>
  196. </Col>
  197. </Row>
  198. </li>
  199. {users.length === 0 && (
  200. <li>
  201. <Row>
  202. <Col md={12} className="text-centered">
  203. <small>{t('no_members')}</small>
  204. </Col>
  205. </Row>
  206. </li>
  207. )}
  208. {users.map(user => (
  209. <GroupMemberRow
  210. key={user.email}
  211. user={user}
  212. selectUser={selectUser}
  213. unselectUser={unselectUser}
  214. selected={selectedUsers.includes(user)}
  215. />
  216. ))}
  217. </ul>
  218. </div>
  219. <hr />
  220. <div>
  221. <p className="small">{t('add_more_managers')}</p>
  222. <ErrorAlert error={inviteError} />
  223. <Form horizontal onSubmit={addManagers} className="form">
  224. <Row>
  225. <Col xs={6}>
  226. <FormControl
  227. type="input"
  228. placeholder="jane@example.com, joe@example.com"
  229. aria-describedby="add-members-description"
  230. value={emailString}
  231. onChange={handleEmailsChange}
  232. />
  233. </Col>
  234. <Col xs={4}>
  235. {inviteUserInflightCount > 0 ? (
  236. <Button bsStyle="primary" disabled>
  237. {t('adding')}&hellip;
  238. </Button>
  239. ) : (
  240. <Button bsStyle="primary" onClick={addManagers}>
  241. {t('add')}
  242. </Button>
  243. )}
  244. </Col>
  245. </Row>
  246. <Row>
  247. <Col xs={8}>
  248. <span className="help-block">
  249. {t('add_comma_separated_emails_help')}
  250. </span>
  251. </Col>
  252. </Row>
  253. </Form>
  254. </div>
  255. </div>
  256. </Col>
  257. </Row>
  258. </div>
  259. )
  260. }