managers-table.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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. onChange={handleSelectAllClick}
  169. checked={selectedUsers.length === users.length}
  170. />
  171. <span className="header">{t('email')}</span>
  172. </Col>
  173. <Col xs={4}>
  174. <span className="header">{t('name')}</span>
  175. </Col>
  176. <Col xs={2}>
  177. <Tooltip
  178. id="last-active-tooltip"
  179. description={t('last_active_description')}
  180. overlayProps={{
  181. placement: 'left',
  182. }}
  183. >
  184. <span className="header">
  185. {t('last_active')}
  186. <sup>(?)</sup>
  187. </span>
  188. </Tooltip>
  189. </Col>
  190. <Col xs={2}>
  191. <span className="header">{t('accepted_invite')}</span>
  192. </Col>
  193. </Row>
  194. </li>
  195. {users.length === 0 && (
  196. <li>
  197. <Row>
  198. <Col md={12} className="text-centered">
  199. <small>{t('no_members')}</small>
  200. </Col>
  201. </Row>
  202. </li>
  203. )}
  204. {users.map(user => (
  205. <UserRow
  206. key={user.email}
  207. user={user}
  208. selectUser={selectUser}
  209. unselectUser={unselectUser}
  210. selected={selectedUsers.includes(user)}
  211. />
  212. ))}
  213. </ul>
  214. </div>
  215. <hr />
  216. <div>
  217. <p className="small">{t('add_more_managers')}</p>
  218. <ErrorAlert error={inviteError} />
  219. <Form horizontal onSubmit={addManagers} className="form">
  220. <Row>
  221. <Col xs={6}>
  222. <FormControl
  223. type="input"
  224. placeholder="jane@example.com, joe@example.com"
  225. aria-describedby="add-members-description"
  226. value={emailString}
  227. onChange={handleEmailsChange}
  228. />
  229. </Col>
  230. <Col xs={4}>
  231. {inviteUserInflightCount > 0 ? (
  232. <Button bsStyle="primary" disabled>
  233. {t('adding')}&hellip;
  234. </Button>
  235. ) : (
  236. <Button bsStyle="primary" onClick={addManagers}>
  237. {t('add')}
  238. </Button>
  239. )}
  240. </Col>
  241. </Row>
  242. <Row>
  243. <Col xs={8}>
  244. <span className="help-block">
  245. {t('add_comma_separated_emails_help')}
  246. </span>
  247. </Col>
  248. </Row>
  249. </Form>
  250. </div>
  251. </div>
  252. </Col>
  253. </Row>
  254. </div>
  255. )
  256. }