-
Notifications
You must be signed in to change notification settings - Fork 171
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Remix projects access requests actions dropdown #5155
Changes from 7 commits
9631223
d4d13d9
1c37f60
645b73a
332e935
7fa80a0
e256171
c23c9b5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,34 +1,40 @@ | ||
import { Dialog, Flex, IconButton, Text, Button, DropdownMenu, Separator } from '@radix-ui/themes' | ||
import { | ||
CaretDownIcon, | ||
CheckIcon, | ||
ChevronDownIcon, | ||
Cross2Icon, | ||
GlobeIcon, | ||
LockClosedIcon, | ||
Link2Icon, | ||
LockClosedIcon, | ||
MinusCircledIcon, | ||
PersonIcon, | ||
} from '@radix-ui/react-icons' | ||
import { Button, Dialog, DropdownMenu, Flex, IconButton, Separator, Text } from '@radix-ui/themes' | ||
import { AnimatePresence, motion } from 'framer-motion' | ||
import moment from 'moment' | ||
import React from 'react' | ||
import { useFetcherDataUnkown } from '../hooks/useFetcherData' | ||
import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation' | ||
import { useProjectAccessMatchesSelectedCategory } from '../hooks/useProjectMatchingCategory' | ||
import { useProjectsStore } from '../stores/projectsStore' | ||
import { sprinkles } from '../styles/sprinkles.css' | ||
import type { UpdateAccessRequestAction } from '../types' | ||
import { | ||
AccessLevel, | ||
AccessRequestStatus, | ||
asAccessLevel, | ||
operationApproveAccessRequest, | ||
mustAccessRequestStatus, | ||
operationChangeAccess, | ||
type ProjectListing, | ||
AccessRequestStatus, | ||
operationUpdateAccessRequest, | ||
type ProjectAccessRequestWithUserDetails, | ||
type ProjectListing, | ||
} from '../types' | ||
import { AccessLevel } from '../types' | ||
import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation' | ||
import React from 'react' | ||
import { when } from '../util/react-conditionals' | ||
import moment from 'moment' | ||
import { useProjectEditorLink } from '../util/links' | ||
import { assertNever } from '../util/assertNever' | ||
import { useCopyProjectLinkToClipboard } from '../util/copyProjectLink' | ||
import { useProjectsStore } from '../stores/projectsStore' | ||
import { AnimatePresence, motion } from 'framer-motion' | ||
import { useFetcherDataUnkown } from '../hooks/useFetcherData' | ||
import { useProjectAccessMatchesSelectedCategory } from '../hooks/useProjectMatchingCategory' | ||
import { sprinkles } from '../styles/sprinkles.css' | ||
import { Spinner } from './spinner' | ||
import { isLikeApiError } from '../util/errors' | ||
import { useProjectEditorLink } from '../util/links' | ||
import { unless, when } from '../util/react-conditionals' | ||
import { Spinner } from './spinner' | ||
import { UserAvatar } from './userAvatar' | ||
|
||
export const SharingDialogWrapper = React.memo( | ||
|
@@ -147,22 +153,6 @@ type AccessRequestListProps = { | |
const AccessRequestsList = React.memo(({ projectId, accessLevel }: AccessRequestListProps) => { | ||
const accessRequests = useProjectsStore((store) => store.sharingProjectAccessRequests) | ||
|
||
const approveAccessRequestFetcher = useFetcherWithOperation(projectId, 'approveAccessRequest') | ||
|
||
const approveAccessRequest = React.useCallback( | ||
(tokenId: string) => { | ||
approveAccessRequestFetcher.submit( | ||
operationApproveAccessRequest(projectId, tokenId), | ||
{ tokenId: tokenId }, | ||
{ | ||
method: 'POST', | ||
action: `/internal/projects/${projectId}/access/request/${tokenId}/approve`, | ||
}, | ||
) | ||
}, | ||
[approveAccessRequestFetcher, projectId], | ||
) | ||
|
||
const hasGonePrivate = React.useMemo(() => { | ||
return accessRequests.requests.length > 0 && accessLevel === AccessLevel.PRIVATE | ||
}, [accessLevel, accessRequests]) | ||
|
@@ -192,7 +182,6 @@ const AccessRequestsList = React.memo(({ projectId, accessLevel }: AccessRequest | |
<AccessRequests | ||
projectId={projectId} | ||
projectAccessLevel={accessLevel} | ||
approveAccessRequest={approveAccessRequest} | ||
accessRequests={accessRequests.requests} | ||
/> | ||
</Flex> | ||
|
@@ -234,79 +223,178 @@ const OwnerCollaboratorRow = React.memo(() => { | |
}) | ||
OwnerCollaboratorRow.displayName = 'OwnerCollaboratorRow' | ||
|
||
function AccessRequests({ | ||
projectId, | ||
projectAccessLevel, | ||
approveAccessRequest, | ||
accessRequests, | ||
}: { | ||
function AccessRequests(props: { | ||
projectId: string | ||
projectAccessLevel: AccessLevel | ||
approveAccessRequest: (projectId: string, tokenId: string) => void | ||
accessRequests: ProjectAccessRequestWithUserDetails[] | ||
}) { | ||
const onApprove = React.useCallback( | ||
(token: string) => () => { | ||
approveAccessRequest(projectId, token) | ||
const { projectId, projectAccessLevel } = props | ||
|
||
// the access requests, including in-flight optimistic statuses | ||
const [accessRequests, setAccessRequests] = React.useState(props.accessRequests) | ||
// the last successfully-obtained access requests that can be used to roll-back in case of issues when updating requests | ||
const [stableAccessRequests, setStableAccessRequests] = React.useState(props.accessRequests) | ||
|
||
const updateAccessRequestFetcher = useFetcherWithOperation(projectId, 'updateAccessRequest') | ||
const onUpdateAccessRequest = React.useCallback( | ||
(token: string, action: UpdateAccessRequestAction) => () => { | ||
setStableAccessRequests(accessRequests) | ||
setAccessRequests((reqs) => { | ||
switch (action) { | ||
case 'destroy': | ||
return reqs.filter((r) => r.token !== token) | ||
case 'approve': | ||
return reqs.map((r) => | ||
r.token === token ? { ...r, status: AccessRequestStatus.APPROVED } : r, | ||
) | ||
case 'reject': | ||
return reqs.map((r) => | ||
r.token === token ? { ...r, status: AccessRequestStatus.REJECTED } : r, | ||
) | ||
default: | ||
assertNever(action) | ||
} | ||
}) | ||
updateAccessRequestFetcher.submit( | ||
operationUpdateAccessRequest(projectId, token, action), | ||
{ tokenId: token }, | ||
{ | ||
method: 'POST', | ||
action: `/internal/projects/${projectId}/access/request/${token}/${action}`, | ||
}, | ||
) | ||
}, | ||
[projectId, approveAccessRequest], | ||
[updateAccessRequestFetcher, projectId, accessRequests], | ||
) | ||
const resetAccessRequests = React.useCallback( | ||
(data: unknown) => { | ||
if (isLikeApiError(data)) { | ||
setAccessRequests(stableAccessRequests) | ||
} | ||
}, | ||
[stableAccessRequests], | ||
) | ||
useFetcherDataUnkown(updateAccessRequestFetcher, resetAccessRequests) | ||
|
||
const isCollaborative = React.useMemo(() => { | ||
return projectAccessLevel === AccessLevel.COLLABORATIVE | ||
}, [projectAccessLevel]) | ||
|
||
return accessRequests | ||
.sort((a, b) => { | ||
if (a.status !== b.status) { | ||
return a.status - b.status | ||
} | ||
return moment(a.updated_at).unix() - moment(b.updated_at).unix() | ||
return moment(a.created_at).unix() - moment(b.created_at).unix() | ||
}) | ||
.map((request) => { | ||
const user = request.User | ||
if (user == null) { | ||
return null | ||
} | ||
|
||
const status = request.status | ||
const canBeApproved = isCollaborative && status === AccessRequestStatus.PENDING | ||
const color = | ||
status === AccessRequestStatus.PENDING | ||
? 'gray' | ||
: status === AccessRequestStatus.APPROVED | ||
? 'green' | ||
: 'red' | ||
const status = mustAccessRequestStatus(request.status) | ||
|
||
return ( | ||
<CollaboratorRow | ||
key={request.token} | ||
key={`collaborator-${request.token}`} | ||
picture={user.picture} | ||
name={user.name ?? user.email ?? user.user_id} | ||
isDisabled={!isCollaborative} | ||
> | ||
{canBeApproved ? ( | ||
<Button size='1' variant='ghost' onClick={onApprove(request.token)}> | ||
Approve | ||
</Button> | ||
) : ( | ||
{when( | ||
isCollaborative, | ||
<AccessRequestDropdown | ||
status={request.status} | ||
onApprove={onUpdateAccessRequest(request.token, 'approve')} | ||
onReject={onUpdateAccessRequest(request.token, 'reject')} | ||
onDestroy={onUpdateAccessRequest(request.token, 'destroy')} | ||
/>, | ||
)} | ||
{unless( | ||
isCollaborative, | ||
<Text | ||
size='1' | ||
color={color} | ||
style={{ | ||
fontStyle: !isCollaborative ? 'italic' : 'normal', | ||
cursor: 'default', | ||
fontStyle: !isCollaborative ? 'italic' : 'normal', | ||
}} | ||
> | ||
{when(status === AccessRequestStatus.PENDING, 'Pending')} | ||
{when(status === AccessRequestStatus.APPROVED, 'Approved')} | ||
{when(status === AccessRequestStatus.REJECTED, 'Rejected')} | ||
</Text> | ||
{when(status === AccessRequestStatus.APPROVED, 'Collaborator')} | ||
{when(status === AccessRequestStatus.REJECTED, 'Blocked')} | ||
</Text>, | ||
)} | ||
</CollaboratorRow> | ||
) | ||
}) | ||
} | ||
|
||
const AccessRequestDropdown = React.memo( | ||
({ | ||
status, | ||
onApprove, | ||
onReject, | ||
onDestroy, | ||
}: { | ||
status: AccessRequestStatus | ||
onApprove: () => void | ||
onReject: () => void | ||
onDestroy: () => void | ||
}) => { | ||
return ( | ||
<DropdownMenu.Root> | ||
<DropdownMenu.Trigger> | ||
{/* this needs to be inlined (and as a ternary) because DropdownMenu.Trigger requires a direct single child */} | ||
{status === AccessRequestStatus.PENDING ? ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we should pull it out into its own component in that case? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah my comment is meant for that case too: it needs an explicit inline child, because otherwise it will explode due to the component not accepting refs (?!). You can see I tried doing it here but had to revert it back :( |
||
<Button size='1' color='amber' radius='full'> | ||
Requests Access | ||
<ChevronDownIcon /> | ||
</Button> | ||
) : status === AccessRequestStatus.APPROVED ? ( | ||
<Button size='1' radius='medium' variant='ghost' color='gray' highContrast={true}> | ||
Collaborator | ||
<ChevronDownIcon /> | ||
</Button> | ||
) : status === AccessRequestStatus.REJECTED ? ( | ||
<Button size='1' radius='medium' variant='ghost' color='red'> | ||
Denied Access | ||
<ChevronDownIcon /> | ||
</Button> | ||
) : null} | ||
</DropdownMenu.Trigger> | ||
<DropdownMenu.Content> | ||
{when( | ||
status !== AccessRequestStatus.REJECTED, | ||
<DropdownMenu.Item style={{ height: 28 }} color={'red'} onClick={onReject}> | ||
<Flex align='center' gap='2'> | ||
<Cross2Icon /> | ||
<Text size='1'>Block</Text> | ||
</Flex> | ||
</DropdownMenu.Item>, | ||
)} | ||
{when( | ||
status !== AccessRequestStatus.APPROVED, | ||
<DropdownMenu.Item style={{ height: 28 }} onClick={onApprove}> | ||
<Flex align='center' gap='2'> | ||
<CheckIcon /> | ||
<Text size='1'>Allow To Collaborate</Text> | ||
</Flex> | ||
</DropdownMenu.Item>, | ||
)} | ||
<DropdownMenu.Separator /> | ||
<DropdownMenu.Item style={{ height: 28 }} color='gray' onClick={onDestroy}> | ||
<Flex align='center' gap='2'> | ||
<MinusCircledIcon /> | ||
<Text size='1'> | ||
{status === AccessRequestStatus.PENDING ? 'Delete Request' : 'Remove From Project'} | ||
</Text> | ||
</Flex> | ||
</DropdownMenu.Item> | ||
</DropdownMenu.Content> | ||
</DropdownMenu.Root> | ||
) | ||
}, | ||
) | ||
AccessRequestDropdown.displayName = 'AccessRequestDropdown' | ||
|
||
const CollaboratorRow = React.memo( | ||
({ | ||
picture, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably be called
previousAccessRequests