managers-table.tsx 8.6 KB

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