diff --git a/utopia-remix/app/components/sharingDialog.tsx b/utopia-remix/app/components/sharingDialog.tsx index 9ed3eb880b6e..4c9fc293fbcb 100644 --- a/utopia-remix/app/components/sharingDialog.tsx +++ b/utopia-remix/app/components/sharingDialog.tsx @@ -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 @@ -234,23 +223,58 @@ 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 [previousAccessRequests, setPreviousAccessRequests] = React.useState(props.accessRequests) + + const updateAccessRequestFetcher = useFetcherWithOperation(projectId, 'updateAccessRequest') + const onUpdateAccessRequest = React.useCallback( + (token: string, action: UpdateAccessRequestAction) => () => { + setPreviousAccessRequests(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(previousAccessRequests) + } + }, + [previousAccessRequests], + ) + useFetcherDataUnkown(updateAccessRequestFetcher, resetAccessRequests) const isCollaborative = React.useMemo(() => { return projectAccessLevel === AccessLevel.COLLABORATIVE @@ -258,10 +282,7 @@ function AccessRequests({ 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 @@ -269,44 +290,111 @@ function AccessRequests({ 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 ( - {canBeApproved ? ( - - ) : ( + {when( + isCollaborative, + , + )} + {unless( + isCollaborative, {when(status === AccessRequestStatus.PENDING, 'Pending')} - {when(status === AccessRequestStatus.APPROVED, 'Approved')} - {when(status === AccessRequestStatus.REJECTED, 'Rejected')} - + {when(status === AccessRequestStatus.APPROVED, 'Collaborator')} + {when(status === AccessRequestStatus.REJECTED, 'Blocked')} + , )} ) }) } +const AccessRequestDropdown = React.memo( + ({ + status, + onApprove, + onReject, + onDestroy, + }: { + status: AccessRequestStatus + onApprove: () => void + onReject: () => void + onDestroy: () => void + }) => { + return ( + + + {/* this needs to be inlined (and as a ternary) because DropdownMenu.Trigger requires a direct single child */} + {status === AccessRequestStatus.PENDING ? ( + + ) : status === AccessRequestStatus.APPROVED ? ( + + ) : status === AccessRequestStatus.REJECTED ? ( + + ) : null} + + + {when( + status !== AccessRequestStatus.REJECTED, + + + + Block + + , + )} + {when( + status !== AccessRequestStatus.APPROVED, + + + + Allow To Collaborate + + , + )} + + + + + + {status === AccessRequestStatus.PENDING ? 'Delete Request' : 'Remove From Project'} + + + + + + ) + }, +) +AccessRequestDropdown.displayName = 'AccessRequestDropdown' + const CollaboratorRow = React.memo( ({ picture, diff --git a/utopia-remix/app/types.ts b/utopia-remix/app/types.ts index 0573164fdd57..9b5d20abc1e9 100644 --- a/utopia-remix/app/types.ts +++ b/utopia-remix/app/types.ts @@ -1,7 +1,7 @@ import type { ProjectAccessRequest, UserDetails } from 'prisma-client' import { Prisma } from 'prisma-client' -import { assertNever } from './util/assertNever' import { ensure } from './util/api.server' +import { assertNever } from './util/assertNever' import { Status } from './util/statusCodes' const fullProjectFromDB = Prisma.validator()({ @@ -170,16 +170,25 @@ export function operationChangeAccess( return { type: 'changeAccess', ...baseOperation(projectId), newAccessLevel: newAccessLevel } } -type OperationApproveAccessRequest = BaseOperation & { - type: 'approveAccessRequest' +export type UpdateAccessRequestAction = 'approve' | 'reject' | 'destroy' + +type OperationUpdateAccessRequest = BaseOperation & { + type: 'updateAccessRequest' tokenId: string + action: UpdateAccessRequestAction } -export function operationApproveAccessRequest( +export function operationUpdateAccessRequest( projectId: string, tokenId: string, -): OperationApproveAccessRequest { - return { type: 'approveAccessRequest', ...baseOperation(projectId), tokenId: tokenId } + action: UpdateAccessRequestAction, +): OperationUpdateAccessRequest { + return { + type: 'updateAccessRequest', + ...baseOperation(projectId), + tokenId: tokenId, + action: action, + } } export type Operation = @@ -188,7 +197,7 @@ export type Operation = | OperationDestroy | OperationRestore | OperationChangeAccess - | OperationApproveAccessRequest + | OperationUpdateAccessRequest export type OperationType = | 'rename' @@ -196,13 +205,13 @@ export type OperationType = | 'destroy' | 'restore' | 'changeAccess' - | 'approveAccessRequest' + | 'updateAccessRequest' export function areBaseOperationsEquivalent(a: Operation, b: Operation): boolean { return a.projectId === b.projectId && a.type === b.type } -export function getOperationDescription(op: Operation, project: ProjectListing) { +export function getOperationDescription(op: Operation, project: ProjectListing): string { switch (op.type) { case 'delete': return `Deleting project ${project.title}` @@ -214,8 +223,18 @@ export function getOperationDescription(op: Operation, project: ProjectListing) return `Restoring project ${project.title}` case 'changeAccess': return `Changing access level of project ${project.title}` - case 'approveAccessRequest': - return `Granting access request to project ${project.title}` + case 'updateAccessRequest': + switch (op.action) { + case 'approve': + return `Granting access request to project ${project.title}` + case 'reject': + return `Rejecting access request to project ${project.title}` + case 'destroy': + return `Deleting access request to project ${project.title}` + default: + assertNever(op.action) + } + break // required for typecheck default: assertNever(op) } @@ -227,6 +246,20 @@ export enum AccessRequestStatus { REJECTED, } +export function mustAccessRequestStatus(n: number): AccessRequestStatus { + const maybe = n as AccessRequestStatus + switch (maybe) { + case AccessRequestStatus.PENDING: + return AccessRequestStatus.PENDING + case AccessRequestStatus.APPROVED: + return AccessRequestStatus.APPROVED + case AccessRequestStatus.REJECTED: + return AccessRequestStatus.REJECTED + default: + assertNever(maybe) + } +} + export type ProjectAccessRequestWithUserDetails = ProjectAccessRequest & { User: UserDetails | null }