managers-table.tsx 8.5 KB

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