Skip to content

Commit 118354b

Browse files
authored
Remix projects access requests actions dropdown (#5155)
* manipulate access requests with dropdown * tidy up imports * split for readability * when/unless * update * rename
1 parent 033ceee commit 118354b

File tree

2 files changed

+200
-79
lines changed

2 files changed

+200
-79
lines changed

utopia-remix/app/components/sharingDialog.tsx

+156-68
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,40 @@
1-
import { Dialog, Flex, IconButton, Text, Button, DropdownMenu, Separator } from '@radix-ui/themes'
21
import {
32
CaretDownIcon,
3+
CheckIcon,
4+
ChevronDownIcon,
45
Cross2Icon,
56
GlobeIcon,
6-
LockClosedIcon,
77
Link2Icon,
8+
LockClosedIcon,
9+
MinusCircledIcon,
810
PersonIcon,
911
} 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'
1022
import {
23+
AccessLevel,
24+
AccessRequestStatus,
1125
asAccessLevel,
12-
operationApproveAccessRequest,
26+
mustAccessRequestStatus,
1327
operationChangeAccess,
14-
type ProjectListing,
15-
AccessRequestStatus,
28+
operationUpdateAccessRequest,
1629
type ProjectAccessRequestWithUserDetails,
30+
type ProjectListing,
1731
} 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'
2433
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'
3134
import { isLikeApiError } from '../util/errors'
35+
import { useProjectEditorLink } from '../util/links'
36+
import { unless, when } from '../util/react-conditionals'
37+
import { Spinner } from './spinner'
3238
import { UserAvatar } from './userAvatar'
3339

3440
export const SharingDialogWrapper = React.memo(
@@ -147,22 +153,6 @@ type AccessRequestListProps = {
147153
const AccessRequestsList = React.memo(({ projectId, accessLevel }: AccessRequestListProps) => {
148154
const accessRequests = useProjectsStore((store) => store.sharingProjectAccessRequests)
149155

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-
166156
const hasGonePrivate = React.useMemo(() => {
167157
return accessRequests.requests.length > 0 && accessLevel === AccessLevel.PRIVATE
168158
}, [accessLevel, accessRequests])
@@ -192,7 +182,6 @@ const AccessRequestsList = React.memo(({ projectId, accessLevel }: AccessRequest
192182
<AccessRequests
193183
projectId={projectId}
194184
projectAccessLevel={accessLevel}
195-
approveAccessRequest={approveAccessRequest}
196185
accessRequests={accessRequests.requests}
197186
/>
198187
</Flex>
@@ -234,79 +223,178 @@ const OwnerCollaboratorRow = React.memo(() => {
234223
})
235224
OwnerCollaboratorRow.displayName = 'OwnerCollaboratorRow'
236225

237-
function AccessRequests({
238-
projectId,
239-
projectAccessLevel,
240-
approveAccessRequest,
241-
accessRequests,
242-
}: {
226+
function AccessRequests(props: {
243227
projectId: string
244228
projectAccessLevel: AccessLevel
245-
approveAccessRequest: (projectId: string, tokenId: string) => void
246229
accessRequests: ProjectAccessRequestWithUserDetails[]
247230
}) {
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+
)
251266
},
252-
[projectId, approveAccessRequest],
267+
[updateAccessRequestFetcher, projectId, accessRequests],
253268
)
269+
const resetAccessRequests = React.useCallback(
270+
(data: unknown) => {
271+
if (isLikeApiError(data)) {
272+
setAccessRequests(previousAccessRequests)
273+
}
274+
},
275+
[previousAccessRequests],
276+
)
277+
useFetcherDataUnkown(updateAccessRequestFetcher, resetAccessRequests)
254278

255279
const isCollaborative = React.useMemo(() => {
256280
return projectAccessLevel === AccessLevel.COLLABORATIVE
257281
}, [projectAccessLevel])
258282

259283
return accessRequests
260284
.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()
265286
})
266287
.map((request) => {
267288
const user = request.User
268289
if (user == null) {
269290
return null
270291
}
271292

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+
280295
return (
281296
<CollaboratorRow
282-
key={request.token}
297+
key={`collaborator-${request.token}`}
283298
picture={user.picture}
284299
name={user.name ?? user.email ?? user.user_id}
285300
isDisabled={!isCollaborative}
286301
>
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,
292313
<Text
293314
size='1'
294-
color={color}
295315
style={{
296-
fontStyle: !isCollaborative ? 'italic' : 'normal',
297316
cursor: 'default',
317+
fontStyle: !isCollaborative ? 'italic' : 'normal',
298318
}}
299319
>
300320
{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>,
304324
)}
305325
</CollaboratorRow>
306326
)
307327
})
308328
}
309329

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+
310398
const CollaboratorRow = React.memo(
311399
({
312400
picture,

0 commit comments

Comments
 (0)