|
1 |
| -import { Dialog, Flex, IconButton, Text, Button, DropdownMenu, Separator } from '@radix-ui/themes' |
2 | 1 | import {
|
3 | 2 | CaretDownIcon,
|
| 3 | + CheckIcon, |
| 4 | + ChevronDownIcon, |
4 | 5 | Cross2Icon,
|
5 | 6 | GlobeIcon,
|
6 |
| - LockClosedIcon, |
7 | 7 | Link2Icon,
|
| 8 | + LockClosedIcon, |
| 9 | + MinusCircledIcon, |
8 | 10 | PersonIcon,
|
9 | 11 | } from '@radix-ui/react-icons'
|
| 12 | +import { Button, Dialog, DropdownMenu, Flex, IconButton, Separator, Text } from '@radix-ui/themes' |
| 13 | +import { AnimatePresence, motion } from 'framer-motion' |
| 14 | +import moment from 'moment' |
| 15 | +import React from 'react' |
| 16 | +import { useFetcherDataUnkown } from '../hooks/useFetcherData' |
| 17 | +import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation' |
| 18 | +import { useProjectAccessMatchesSelectedCategory } from '../hooks/useProjectMatchingCategory' |
| 19 | +import { useProjectsStore } from '../stores/projectsStore' |
| 20 | +import { sprinkles } from '../styles/sprinkles.css' |
| 21 | +import type { UpdateAccessRequestAction } from '../types' |
10 | 22 | import {
|
| 23 | + AccessLevel, |
| 24 | + AccessRequestStatus, |
11 | 25 | asAccessLevel,
|
12 |
| - operationApproveAccessRequest, |
| 26 | + mustAccessRequestStatus, |
13 | 27 | operationChangeAccess,
|
14 |
| - type ProjectListing, |
15 |
| - AccessRequestStatus, |
| 28 | + operationUpdateAccessRequest, |
16 | 29 | type ProjectAccessRequestWithUserDetails,
|
| 30 | + type ProjectListing, |
17 | 31 | } from '../types'
|
18 |
| -import { AccessLevel } from '../types' |
19 |
| -import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation' |
20 |
| -import React from 'react' |
21 |
| -import { when } from '../util/react-conditionals' |
22 |
| -import moment from 'moment' |
23 |
| -import { useProjectEditorLink } from '../util/links' |
| 32 | +import { assertNever } from '../util/assertNever' |
24 | 33 | import { useCopyProjectLinkToClipboard } from '../util/copyProjectLink'
|
25 |
| -import { useProjectsStore } from '../stores/projectsStore' |
26 |
| -import { AnimatePresence, motion } from 'framer-motion' |
27 |
| -import { useFetcherDataUnkown } from '../hooks/useFetcherData' |
28 |
| -import { useProjectAccessMatchesSelectedCategory } from '../hooks/useProjectMatchingCategory' |
29 |
| -import { sprinkles } from '../styles/sprinkles.css' |
30 |
| -import { Spinner } from './spinner' |
31 | 34 | import { isLikeApiError } from '../util/errors'
|
| 35 | +import { useProjectEditorLink } from '../util/links' |
| 36 | +import { unless, when } from '../util/react-conditionals' |
| 37 | +import { Spinner } from './spinner' |
32 | 38 | import { UserAvatar } from './userAvatar'
|
33 | 39 |
|
34 | 40 | export const SharingDialogWrapper = React.memo(
|
@@ -147,22 +153,6 @@ type AccessRequestListProps = {
|
147 | 153 | const AccessRequestsList = React.memo(({ projectId, accessLevel }: AccessRequestListProps) => {
|
148 | 154 | const accessRequests = useProjectsStore((store) => store.sharingProjectAccessRequests)
|
149 | 155 |
|
150 |
| - const approveAccessRequestFetcher = useFetcherWithOperation(projectId, 'approveAccessRequest') |
151 |
| - |
152 |
| - const approveAccessRequest = React.useCallback( |
153 |
| - (tokenId: string) => { |
154 |
| - approveAccessRequestFetcher.submit( |
155 |
| - operationApproveAccessRequest(projectId, tokenId), |
156 |
| - { tokenId: tokenId }, |
157 |
| - { |
158 |
| - method: 'POST', |
159 |
| - action: `/internal/projects/${projectId}/access/request/${tokenId}/approve`, |
160 |
| - }, |
161 |
| - ) |
162 |
| - }, |
163 |
| - [approveAccessRequestFetcher, projectId], |
164 |
| - ) |
165 |
| - |
166 | 156 | const hasGonePrivate = React.useMemo(() => {
|
167 | 157 | return accessRequests.requests.length > 0 && accessLevel === AccessLevel.PRIVATE
|
168 | 158 | }, [accessLevel, accessRequests])
|
@@ -192,7 +182,6 @@ const AccessRequestsList = React.memo(({ projectId, accessLevel }: AccessRequest
|
192 | 182 | <AccessRequests
|
193 | 183 | projectId={projectId}
|
194 | 184 | projectAccessLevel={accessLevel}
|
195 |
| - approveAccessRequest={approveAccessRequest} |
196 | 185 | accessRequests={accessRequests.requests}
|
197 | 186 | />
|
198 | 187 | </Flex>
|
@@ -234,79 +223,178 @@ const OwnerCollaboratorRow = React.memo(() => {
|
234 | 223 | })
|
235 | 224 | OwnerCollaboratorRow.displayName = 'OwnerCollaboratorRow'
|
236 | 225 |
|
237 |
| -function AccessRequests({ |
238 |
| - projectId, |
239 |
| - projectAccessLevel, |
240 |
| - approveAccessRequest, |
241 |
| - accessRequests, |
242 |
| -}: { |
| 226 | +function AccessRequests(props: { |
243 | 227 | projectId: string
|
244 | 228 | projectAccessLevel: AccessLevel
|
245 |
| - approveAccessRequest: (projectId: string, tokenId: string) => void |
246 | 229 | accessRequests: ProjectAccessRequestWithUserDetails[]
|
247 | 230 | }) {
|
248 |
| - const onApprove = React.useCallback( |
249 |
| - (token: string) => () => { |
250 |
| - approveAccessRequest(projectId, token) |
| 231 | + const { projectId, projectAccessLevel } = props |
| 232 | + |
| 233 | + // the access requests, including in-flight optimistic statuses |
| 234 | + const [accessRequests, setAccessRequests] = React.useState(props.accessRequests) |
| 235 | + // the last successfully-obtained access requests that can be used to roll-back in case of issues when updating requests |
| 236 | + const [previousAccessRequests, setPreviousAccessRequests] = React.useState(props.accessRequests) |
| 237 | + |
| 238 | + const updateAccessRequestFetcher = useFetcherWithOperation(projectId, 'updateAccessRequest') |
| 239 | + const onUpdateAccessRequest = React.useCallback( |
| 240 | + (token: string, action: UpdateAccessRequestAction) => () => { |
| 241 | + setPreviousAccessRequests(accessRequests) |
| 242 | + setAccessRequests((reqs) => { |
| 243 | + switch (action) { |
| 244 | + case 'destroy': |
| 245 | + return reqs.filter((r) => r.token !== token) |
| 246 | + case 'approve': |
| 247 | + return reqs.map((r) => |
| 248 | + r.token === token ? { ...r, status: AccessRequestStatus.APPROVED } : r, |
| 249 | + ) |
| 250 | + case 'reject': |
| 251 | + return reqs.map((r) => |
| 252 | + r.token === token ? { ...r, status: AccessRequestStatus.REJECTED } : r, |
| 253 | + ) |
| 254 | + default: |
| 255 | + assertNever(action) |
| 256 | + } |
| 257 | + }) |
| 258 | + updateAccessRequestFetcher.submit( |
| 259 | + operationUpdateAccessRequest(projectId, token, action), |
| 260 | + { tokenId: token }, |
| 261 | + { |
| 262 | + method: 'POST', |
| 263 | + action: `/internal/projects/${projectId}/access/request/${token}/${action}`, |
| 264 | + }, |
| 265 | + ) |
251 | 266 | },
|
252 |
| - [projectId, approveAccessRequest], |
| 267 | + [updateAccessRequestFetcher, projectId, accessRequests], |
253 | 268 | )
|
| 269 | + const resetAccessRequests = React.useCallback( |
| 270 | + (data: unknown) => { |
| 271 | + if (isLikeApiError(data)) { |
| 272 | + setAccessRequests(previousAccessRequests) |
| 273 | + } |
| 274 | + }, |
| 275 | + [previousAccessRequests], |
| 276 | + ) |
| 277 | + useFetcherDataUnkown(updateAccessRequestFetcher, resetAccessRequests) |
254 | 278 |
|
255 | 279 | const isCollaborative = React.useMemo(() => {
|
256 | 280 | return projectAccessLevel === AccessLevel.COLLABORATIVE
|
257 | 281 | }, [projectAccessLevel])
|
258 | 282 |
|
259 | 283 | return accessRequests
|
260 | 284 | .sort((a, b) => {
|
261 |
| - if (a.status !== b.status) { |
262 |
| - return a.status - b.status |
263 |
| - } |
264 |
| - return moment(a.updated_at).unix() - moment(b.updated_at).unix() |
| 285 | + return moment(a.created_at).unix() - moment(b.created_at).unix() |
265 | 286 | })
|
266 | 287 | .map((request) => {
|
267 | 288 | const user = request.User
|
268 | 289 | if (user == null) {
|
269 | 290 | return null
|
270 | 291 | }
|
271 | 292 |
|
272 |
| - const status = request.status |
273 |
| - const canBeApproved = isCollaborative && status === AccessRequestStatus.PENDING |
274 |
| - const color = |
275 |
| - status === AccessRequestStatus.PENDING |
276 |
| - ? 'gray' |
277 |
| - : status === AccessRequestStatus.APPROVED |
278 |
| - ? 'green' |
279 |
| - : 'red' |
| 293 | + const status = mustAccessRequestStatus(request.status) |
| 294 | + |
280 | 295 | return (
|
281 | 296 | <CollaboratorRow
|
282 |
| - key={request.token} |
| 297 | + key={`collaborator-${request.token}`} |
283 | 298 | picture={user.picture}
|
284 | 299 | name={user.name ?? user.email ?? user.user_id}
|
285 | 300 | isDisabled={!isCollaborative}
|
286 | 301 | >
|
287 |
| - {canBeApproved ? ( |
288 |
| - <Button size='1' variant='ghost' onClick={onApprove(request.token)}> |
289 |
| - Approve |
290 |
| - </Button> |
291 |
| - ) : ( |
| 302 | + {when( |
| 303 | + isCollaborative, |
| 304 | + <AccessRequestDropdown |
| 305 | + status={request.status} |
| 306 | + onApprove={onUpdateAccessRequest(request.token, 'approve')} |
| 307 | + onReject={onUpdateAccessRequest(request.token, 'reject')} |
| 308 | + onDestroy={onUpdateAccessRequest(request.token, 'destroy')} |
| 309 | + />, |
| 310 | + )} |
| 311 | + {unless( |
| 312 | + isCollaborative, |
292 | 313 | <Text
|
293 | 314 | size='1'
|
294 |
| - color={color} |
295 | 315 | style={{
|
296 |
| - fontStyle: !isCollaborative ? 'italic' : 'normal', |
297 | 316 | cursor: 'default',
|
| 317 | + fontStyle: !isCollaborative ? 'italic' : 'normal', |
298 | 318 | }}
|
299 | 319 | >
|
300 | 320 | {when(status === AccessRequestStatus.PENDING, 'Pending')}
|
301 |
| - {when(status === AccessRequestStatus.APPROVED, 'Approved')} |
302 |
| - {when(status === AccessRequestStatus.REJECTED, 'Rejected')} |
303 |
| - </Text> |
| 321 | + {when(status === AccessRequestStatus.APPROVED, 'Collaborator')} |
| 322 | + {when(status === AccessRequestStatus.REJECTED, 'Blocked')} |
| 323 | + </Text>, |
304 | 324 | )}
|
305 | 325 | </CollaboratorRow>
|
306 | 326 | )
|
307 | 327 | })
|
308 | 328 | }
|
309 | 329 |
|
| 330 | +const AccessRequestDropdown = React.memo( |
| 331 | + ({ |
| 332 | + status, |
| 333 | + onApprove, |
| 334 | + onReject, |
| 335 | + onDestroy, |
| 336 | + }: { |
| 337 | + status: AccessRequestStatus |
| 338 | + onApprove: () => void |
| 339 | + onReject: () => void |
| 340 | + onDestroy: () => void |
| 341 | + }) => { |
| 342 | + return ( |
| 343 | + <DropdownMenu.Root> |
| 344 | + <DropdownMenu.Trigger> |
| 345 | + {/* this needs to be inlined (and as a ternary) because DropdownMenu.Trigger requires a direct single child */} |
| 346 | + {status === AccessRequestStatus.PENDING ? ( |
| 347 | + <Button size='1' color='amber' radius='full'> |
| 348 | + Requests Access |
| 349 | + <ChevronDownIcon /> |
| 350 | + </Button> |
| 351 | + ) : status === AccessRequestStatus.APPROVED ? ( |
| 352 | + <Button size='1' radius='medium' variant='ghost' color='gray' highContrast={true}> |
| 353 | + Collaborator |
| 354 | + <ChevronDownIcon /> |
| 355 | + </Button> |
| 356 | + ) : status === AccessRequestStatus.REJECTED ? ( |
| 357 | + <Button size='1' radius='medium' variant='ghost' color='red'> |
| 358 | + Denied Access |
| 359 | + <ChevronDownIcon /> |
| 360 | + </Button> |
| 361 | + ) : null} |
| 362 | + </DropdownMenu.Trigger> |
| 363 | + <DropdownMenu.Content> |
| 364 | + {when( |
| 365 | + status !== AccessRequestStatus.REJECTED, |
| 366 | + <DropdownMenu.Item style={{ height: 28 }} color={'red'} onClick={onReject}> |
| 367 | + <Flex align='center' gap='2'> |
| 368 | + <Cross2Icon /> |
| 369 | + <Text size='1'>Block</Text> |
| 370 | + </Flex> |
| 371 | + </DropdownMenu.Item>, |
| 372 | + )} |
| 373 | + {when( |
| 374 | + status !== AccessRequestStatus.APPROVED, |
| 375 | + <DropdownMenu.Item style={{ height: 28 }} onClick={onApprove}> |
| 376 | + <Flex align='center' gap='2'> |
| 377 | + <CheckIcon /> |
| 378 | + <Text size='1'>Allow To Collaborate</Text> |
| 379 | + </Flex> |
| 380 | + </DropdownMenu.Item>, |
| 381 | + )} |
| 382 | + <DropdownMenu.Separator /> |
| 383 | + <DropdownMenu.Item style={{ height: 28 }} color='gray' onClick={onDestroy}> |
| 384 | + <Flex align='center' gap='2'> |
| 385 | + <MinusCircledIcon /> |
| 386 | + <Text size='1'> |
| 387 | + {status === AccessRequestStatus.PENDING ? 'Delete Request' : 'Remove From Project'} |
| 388 | + </Text> |
| 389 | + </Flex> |
| 390 | + </DropdownMenu.Item> |
| 391 | + </DropdownMenu.Content> |
| 392 | + </DropdownMenu.Root> |
| 393 | + ) |
| 394 | + }, |
| 395 | +) |
| 396 | +AccessRequestDropdown.displayName = 'AccessRequestDropdown' |
| 397 | + |
310 | 398 | const CollaboratorRow = React.memo(
|
311 | 399 | ({
|
312 | 400 | picture,
|
|
0 commit comments