Skip to content
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

Merged
merged 8 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 156 additions & 68 deletions utopia-remix/app/components/sharingDialog.tsx
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(
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -192,7 +182,6 @@ const AccessRequestsList = React.memo(({ projectId, accessLevel }: AccessRequest
<AccessRequests
projectId={projectId}
projectAccessLevel={accessLevel}
approveAccessRequest={approveAccessRequest}
accessRequests={accessRequests.requests}
/>
</Flex>
Expand Down Expand Up @@ -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)

Copy link
Contributor

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

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 ? (
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 :( 645b73a (#5155)

<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,
Expand Down
Loading
Loading