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
}