From 9631223d499821869b38f9a3c54a5e992e0b6f2b Mon Sep 17 00:00:00 2001
From: Federico Ruggi <1081051+ruggi@users.noreply.github.com>
Date: Thu, 28 Mar 2024 17:30:40 +0100
Subject: [PATCH 1/6] manipulate access requests with dropdown
---
utopia-remix/app/components/sharingDialog.tsx | 166 +++++++++++++-----
.../projectAccessRequest.server.spec.ts | 96 ++++++++++
.../app/models/projectAccessRequest.server.ts | 23 +++
....$id.access.request.$token.destroy.spec.ts | 70 ++++++++
...ects.$id.access.request.$token.destroy.tsx | 36 ++++
utopia-remix/app/types.ts | 39 ++--
6 files changed, 373 insertions(+), 57 deletions(-)
create mode 100644 utopia-remix/app/routes-test/internal.projects.$id.access.request.$token.destroy.spec.ts
create mode 100644 utopia-remix/app/routes/internal.projects.$id.access.request.$token.destroy.tsx
diff --git a/utopia-remix/app/components/sharingDialog.tsx b/utopia-remix/app/components/sharingDialog.tsx
index 9ed3eb880b6e..1c8860851065 100644
--- a/utopia-remix/app/components/sharingDialog.tsx
+++ b/utopia-remix/app/components/sharingDialog.tsx
@@ -6,14 +6,18 @@ import {
LockClosedIcon,
Link2Icon,
PersonIcon,
+ CheckIcon,
+ ChevronDownIcon,
+ MinusCircledIcon,
} from '@radix-ui/react-icons'
+import type { UpdateAccessRequestAction } from '../types'
import {
asAccessLevel,
- operationApproveAccessRequest,
operationChangeAccess,
type ProjectListing,
AccessRequestStatus,
type ProjectAccessRequestWithUserDetails,
+ operationUpdateAccessRequest,
} from '../types'
import { AccessLevel } from '../types'
import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation'
@@ -30,6 +34,7 @@ import { sprinkles } from '../styles/sprinkles.css'
import { Spinner } from './spinner'
import { isLikeApiError } from '../util/errors'
import { UserAvatar } from './userAvatar'
+import { assertNever } from '../util/assertNever'
export const SharingDialogWrapper = React.memo(
({ project }: { project: ProjectListing | null }) => {
@@ -147,22 +152,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 +181,6 @@ const AccessRequestsList = React.memo(({ projectId, accessLevel }: AccessRequest
@@ -234,23 +222,59 @@ 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) => {
+ return action === 'destroy'
+ ? reqs.filter((r) => r.token !== token)
+ : reqs.map((r) => {
+ if (r.token === token) {
+ switch (action) {
+ case 'approve':
+ return { ...r, status: AccessRequestStatus.APPROVED }
+ case 'reject':
+ return { ...r, status: AccessRequestStatus.REJECTED }
+ default:
+ assertNever(action)
+ }
+ }
+ return r
+ })
+ })
+ 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
@@ -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
@@ -270,13 +291,6 @@ function AccessRequests({
}
const status = request.status
- const canBeApproved = isCollaborative && status === AccessRequestStatus.PENDING
- const color =
- status === AccessRequestStatus.PENDING
- ? 'gray'
- : status === AccessRequestStatus.APPROVED
- ? 'green'
- : 'red'
return (
- {canBeApproved ? (
-
+ {isCollaborative ? (
+
+
+ {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'}
+
+
+
+
+
) : (
{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')}
)}
diff --git a/utopia-remix/app/models/projectAccessRequest.server.spec.ts b/utopia-remix/app/models/projectAccessRequest.server.spec.ts
index e1e2dd9887e4..64889bc80fb3 100644
--- a/utopia-remix/app/models/projectAccessRequest.server.spec.ts
+++ b/utopia-remix/app/models/projectAccessRequest.server.spec.ts
@@ -9,6 +9,7 @@ import {
import { AccessRequestStatus, UserProjectRole } from '../types'
import {
createAccessRequest,
+ destroyAccessRequest,
listProjectAccessRequests,
updateAccessRequestStatus,
} from './projectAccessRequest.server'
@@ -327,4 +328,99 @@ describe('projectAccessRequest', () => {
expect(got[3].User?.name).toBe('person4')
})
})
+
+ describe('destroyAccessRequest', () => {
+ let revokeAllRolesFromUserMock: jest.SpyInstance
+
+ afterEach(async () => {
+ await truncateTables([
+ prisma.projectID,
+ prisma.projectAccessRequest,
+ prisma.projectCollaborator,
+ prisma.project,
+ prisma.userDetails,
+ ])
+
+ revokeAllRolesFromUserMock.mockRestore()
+ })
+
+ beforeEach(async () => {
+ await createTestUser(prisma, { id: 'bob' })
+ await createTestUser(prisma, { id: 'alice' })
+ await createTestUser(prisma, { id: 'carol' })
+ await createTestUser(prisma, { id: 'dorothy' })
+ await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
+ await createTestProject(prisma, { id: 'two', ownerId: 'alice' })
+ await createTestProject(prisma, { id: 'three', ownerId: 'alice' })
+ await createTestProjectAccessRequest(prisma, {
+ projectId: 'two',
+ token: 'token1',
+ userId: 'carol',
+ status: AccessRequestStatus.PENDING,
+ })
+ await createTestProjectAccessRequest(prisma, {
+ projectId: 'three',
+ token: 'token2',
+ userId: 'dorothy',
+ status: AccessRequestStatus.PENDING,
+ })
+ await createTestProjectAccessRequest(prisma, {
+ projectId: 'two',
+ token: 'token3',
+ userId: 'bob',
+ status: AccessRequestStatus.PENDING,
+ })
+
+ revokeAllRolesFromUserMock = jest.spyOn(permissionsService, 'revokeAllRolesFromUser')
+ })
+ it('errors if the project is not found', async () => {
+ const fn = async () =>
+ destroyAccessRequest({ projectId: 'UNKNOWN', ownerId: 'bob', token: 'token1' })
+ await expect(fn).rejects.toThrow('Record to delete does not exist')
+ })
+ it('errors if the project is not owned by the user', async () => {
+ const fn = async () =>
+ destroyAccessRequest({ projectId: 'two', ownerId: 'bob', token: 'token1' })
+ await expect(fn).rejects.toThrow('Record to delete does not exist')
+ })
+ it('errors if the token is not found', async () => {
+ const fn = async () =>
+ destroyAccessRequest({ projectId: 'two', ownerId: 'alice', token: 'tokenWRONG' })
+ await expect(fn).rejects.toThrow('Record to delete does not exist')
+ })
+ it('errors if the token exists but not on that project', async () => {
+ const fn = async () =>
+ destroyAccessRequest({ projectId: 'two', ownerId: 'alice', token: 'token2' })
+ await expect(fn).rejects.toThrow('Record to delete does not exist')
+ })
+ it('deletes the request and revokes roles on FGA', async () => {
+ const existing = await prisma.projectAccessRequest.findMany({ where: { project_id: 'two' } })
+ expect(existing.length).toBe(2)
+ await destroyAccessRequest({ projectId: 'two', ownerId: 'alice', token: 'token1' })
+ const current = await prisma.projectAccessRequest.findMany({
+ where: { project_id: 'two' },
+ })
+ expect(current.length).toBe(1)
+ expect(current[0].user_id).toBe('bob')
+
+ expect(revokeAllRolesFromUserMock).toHaveBeenCalledWith('two', 'carol')
+ })
+ it('rolls back if the FGA revoking fails', async () => {
+ revokeAllRolesFromUserMock.mockImplementation(() => {
+ throw new Error('boom')
+ })
+
+ const existing = await prisma.projectAccessRequest.findMany({ where: { project_id: 'two' } })
+ expect(existing.length).toBe(2)
+
+ const fn = async () =>
+ await destroyAccessRequest({ projectId: 'two', ownerId: 'alice', token: 'token1' })
+ await expect(fn).rejects.toThrow('boom')
+
+ const current = await prisma.projectAccessRequest.findMany({
+ where: { project_id: 'two' },
+ })
+ expect(current.length).toBe(2)
+ })
+ })
})
diff --git a/utopia-remix/app/models/projectAccessRequest.server.ts b/utopia-remix/app/models/projectAccessRequest.server.ts
index ed426632885f..4f0064f148fe 100644
--- a/utopia-remix/app/models/projectAccessRequest.server.ts
+++ b/utopia-remix/app/models/projectAccessRequest.server.ts
@@ -144,3 +144,26 @@ export async function listProjectAccessRequests(params: {
User: users.find((u) => u.user_id === r.user_id) ?? null,
}))
}
+
+export async function destroyAccessRequest(params: {
+ projectId: string
+ ownerId: string
+ token: string
+}) {
+ await prisma.$transaction(async (tx) => {
+ // 1. delete the access request from the DB
+ const request = await tx.projectAccessRequest.delete({
+ where: {
+ project_id_token: {
+ project_id: params.projectId,
+ token: params.token,
+ },
+ Project: {
+ owner_id: params.ownerId,
+ },
+ },
+ })
+ // 2. revoke access on FGA
+ await permissionsService.revokeAllRolesFromUser(params.projectId, request.user_id)
+ })
+}
diff --git a/utopia-remix/app/routes-test/internal.projects.$id.access.request.$token.destroy.spec.ts b/utopia-remix/app/routes-test/internal.projects.$id.access.request.$token.destroy.spec.ts
new file mode 100644
index 000000000000..0cb25dd33488
--- /dev/null
+++ b/utopia-remix/app/routes-test/internal.projects.$id.access.request.$token.destroy.spec.ts
@@ -0,0 +1,70 @@
+import { prisma } from '../db.server'
+import { handleDestroyAccessRequest } from '../routes/internal.projects.$id.access.request.$token.destroy'
+import {
+ createTestProject,
+ createTestProjectAccessRequest,
+ createTestSession,
+ createTestUser,
+ newTestRequest,
+ truncateTables,
+} from '../test-util'
+import { AccessRequestStatus } from '../types'
+
+describe('handleDestroyAccessRequest', () => {
+ afterEach(async () => {
+ await truncateTables([
+ prisma.projectID,
+ prisma.projectAccessRequest,
+ prisma.projectAccess,
+ prisma.persistentSession,
+ prisma.project,
+ prisma.userDetails,
+ ])
+ })
+
+ beforeEach(async () => {
+ await createTestUser(prisma, { id: 'bob' })
+ await createTestUser(prisma, { id: 'alice' })
+ await createTestUser(prisma, { id: 'carol' })
+ await createTestSession(prisma, { userId: 'bob', key: 'the-key' })
+ await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
+ await createTestProjectAccessRequest(prisma, {
+ projectId: 'one',
+ userId: 'alice',
+ status: AccessRequestStatus.PENDING,
+ token: 'alice-token',
+ })
+ await createTestProjectAccessRequest(prisma, {
+ projectId: 'one',
+ userId: 'carol',
+ status: AccessRequestStatus.PENDING,
+ token: 'carol-token',
+ })
+ })
+
+ it('requires a user', async () => {
+ const fn = async () => handleDestroyAccessRequest(newTestRequest(), {})
+ await expect(fn).rejects.toThrow('missing session cookie')
+ })
+
+ it('requires a project id', async () => {
+ const fn = async () => handleDestroyAccessRequest(newTestRequest({ authCookie: 'the-key' }), {})
+ await expect(fn).rejects.toThrow('invalid project id')
+ })
+
+ it('requires a token', async () => {
+ const fn = async () =>
+ handleDestroyAccessRequest(newTestRequest({ authCookie: 'the-key' }), { id: 'one' })
+ await expect(fn).rejects.toThrow('invalid token')
+ })
+
+ it('destroys the access request', async () => {
+ await handleDestroyAccessRequest(newTestRequest({ authCookie: 'the-key' }), {
+ id: 'one',
+ token: 'alice-token',
+ })
+ const reqs = await prisma.projectAccessRequest.findMany({ where: { project_id: 'one' } })
+ expect(reqs.length).toBe(1)
+ expect(reqs[0].user_id).toBe('carol')
+ })
+})
diff --git a/utopia-remix/app/routes/internal.projects.$id.access.request.$token.destroy.tsx b/utopia-remix/app/routes/internal.projects.$id.access.request.$token.destroy.tsx
new file mode 100644
index 000000000000..e06919966d56
--- /dev/null
+++ b/utopia-remix/app/routes/internal.projects.$id.access.request.$token.destroy.tsx
@@ -0,0 +1,36 @@
+import type { ActionFunctionArgs } from '@remix-run/node'
+import type { Params } from '@remix-run/react'
+import { validateProjectAccess } from '../handlers/validators'
+import { UserProjectPermission } from '../types'
+import { ensure, handle, requireUser } from '../util/api.server'
+import { Status } from '../util/statusCodes'
+import { destroyAccessRequest } from '../models/projectAccessRequest.server'
+
+export async function action(args: ActionFunctionArgs) {
+ return handle(args, {
+ POST: {
+ handler: handleDestroyAccessRequest,
+ validator: validateProjectAccess(UserProjectPermission.CAN_MANAGE_PROJECT, {
+ getProjectId: (params) => params.id,
+ }),
+ },
+ })
+}
+
+export async function handleDestroyAccessRequest(req: Request, params: Params) {
+ const user = await requireUser(req)
+
+ const projectId = params.id
+ ensure(projectId != null, 'invalid project id', Status.BAD_REQUEST)
+
+ const token = params.token
+ ensure(token != null && typeof token === 'string', 'invalid token', Status.BAD_REQUEST)
+
+ await destroyAccessRequest({
+ projectId: projectId,
+ ownerId: user.user_id,
+ token: token,
+ })
+
+ return {}
+}
diff --git a/utopia-remix/app/types.ts b/utopia-remix/app/types.ts
index 0573164fdd57..46fea23ac215 100644
--- a/utopia-remix/app/types.ts
+++ b/utopia-remix/app/types.ts
@@ -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)
}
From 1c37f604ee5c98c322e6770c8d9628114bd63858 Mon Sep 17 00:00:00 2001
From: Federico Ruggi <1081051+ruggi@users.noreply.github.com>
Date: Tue, 2 Apr 2024 10:53:51 +0200
Subject: [PATCH 2/6] tidy up imports
---
utopia-remix/app/components/sharingDialog.tsx | 42 +++++++++----------
....$id.access.request.$token.destroy.spec.ts | 3 +-
utopia-remix/app/types.ts | 2 +-
3 files changed, 23 insertions(+), 24 deletions(-)
diff --git a/utopia-remix/app/components/sharingDialog.tsx b/utopia-remix/app/components/sharingDialog.tsx
index 1c8860851065..fed53ec761fb 100644
--- a/utopia-remix/app/components/sharingDialog.tsx
+++ b/utopia-remix/app/components/sharingDialog.tsx
@@ -1,40 +1,40 @@
-import { Dialog, Flex, IconButton, Text, Button, DropdownMenu, Separator } from '@radix-ui/themes'
import {
CaretDownIcon,
+ CheckIcon,
+ ChevronDownIcon,
Cross2Icon,
GlobeIcon,
- LockClosedIcon,
Link2Icon,
- PersonIcon,
- CheckIcon,
- ChevronDownIcon,
+ 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,
operationChangeAccess,
- type ProjectListing,
- AccessRequestStatus,
- type ProjectAccessRequestWithUserDetails,
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 { when } from '../util/react-conditionals'
+import { Spinner } from './spinner'
import { UserAvatar } from './userAvatar'
-import { assertNever } from '../util/assertNever'
export const SharingDialogWrapper = React.memo(
({ project }: { project: ProjectListing | null }) => {
diff --git a/utopia-remix/app/routes-test/internal.projects.$id.access.request.$token.destroy.spec.ts b/utopia-remix/app/routes-test/internal.projects.$id.access.request.$token.destroy.spec.ts
index 479697b373e3..8b3844b2eced 100644
--- a/utopia-remix/app/routes-test/internal.projects.$id.access.request.$token.destroy.spec.ts
+++ b/utopia-remix/app/routes-test/internal.projects.$id.access.request.$token.destroy.spec.ts
@@ -1,6 +1,5 @@
-import { prisma } from '../db.server'
-import { handleDestroyAccessRequest } from '../routes/internal.projects.$id.access.request.$token.destroy'
import type { Params } from '@remix-run/react'
+import { prisma } from '../db.server'
import { action } from '../routes/internal.projects.$id.access.request.$token.destroy'
import {
createTestProject,
diff --git a/utopia-remix/app/types.ts b/utopia-remix/app/types.ts
index 46fea23ac215..3f989a78f7a3 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()({
From 645b73a6fd7aec0c9439d4ac1108af435dd6dc1f Mon Sep 17 00:00:00 2001
From: Federico Ruggi <1081051+ruggi@users.noreply.github.com>
Date: Tue, 2 Apr 2024 11:21:12 +0200
Subject: [PATCH 3/6] split for readability
---
utopia-remix/app/components/sharingDialog.tsx | 184 ++++++++++--------
utopia-remix/app/types.ts | 14 ++
2 files changed, 119 insertions(+), 79 deletions(-)
diff --git a/utopia-remix/app/components/sharingDialog.tsx b/utopia-remix/app/components/sharingDialog.tsx
index fed53ec761fb..78c1467f6af1 100644
--- a/utopia-remix/app/components/sharingDialog.tsx
+++ b/utopia-remix/app/components/sharingDialog.tsx
@@ -23,6 +23,7 @@ import {
AccessLevel,
AccessRequestStatus,
asAccessLevel,
+ mustAccessRequestStatus,
operationChangeAccess,
operationUpdateAccessRequest,
type ProjectAccessRequestWithUserDetails,
@@ -239,21 +240,20 @@ function AccessRequests(props: {
(token: string, action: UpdateAccessRequestAction) => () => {
setStableAccessRequests(accessRequests)
setAccessRequests((reqs) => {
- return action === 'destroy'
- ? reqs.filter((r) => r.token !== token)
- : reqs.map((r) => {
- if (r.token === token) {
- switch (action) {
- case 'approve':
- return { ...r, status: AccessRequestStatus.APPROVED }
- case 'reject':
- return { ...r, status: AccessRequestStatus.REJECTED }
- default:
- assertNever(action)
- }
- }
- return r
- })
+ 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),
@@ -290,7 +290,8 @@ function AccessRequests(props: {
return null
}
- const status = request.status
+ const status = mustAccessRequestStatus(request.status)
+
return (
{isCollaborative ? (
-
-
- {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'}
-
-
-
-
-
+
) : (
{when(status === AccessRequestStatus.PENDING, 'Pending')}
@@ -379,6 +324,87 @@ function AccessRequests(props: {
})
}
+const AccessRequestDropdown = React.memo(
+ ({
+ status,
+ onApprove,
+ onReject,
+ onDestroy,
+ }: {
+ status: AccessRequestStatus
+ onApprove: () => void
+ onReject: () => void
+ onDestroy: () => void
+ }) => {
+ return (
+
+
+
+
+
+ {when(
+ status !== AccessRequestStatus.REJECTED,
+
+
+
+ Block
+
+ ,
+ )}
+ {when(
+ status !== AccessRequestStatus.APPROVED,
+
+
+
+ Allow To Collaborate
+
+ ,
+ )}
+
+
+
+
+
+ {status === AccessRequestStatus.PENDING ? 'Delete Request' : 'Remove From Project'}
+
+
+
+
+
+ )
+ },
+)
+AccessRequestDropdown.displayName = 'AccessRequestDropdown'
+
+const AccessRequestDropdownLabel = React.memo(({ status }: { status: AccessRequestStatus }) => {
+ switch (status) {
+ case AccessRequestStatus.PENDING:
+ return (
+
+ )
+ case AccessRequestStatus.APPROVED:
+ return (
+
+ )
+ case AccessRequestStatus.REJECTED:
+ return (
+
+ )
+ default:
+ assertNever(status)
+ }
+})
+AccessRequestDropdownLabel.displayName = 'AccessRequestDropdownLabel'
+
const CollaboratorRow = React.memo(
({
picture,
diff --git a/utopia-remix/app/types.ts b/utopia-remix/app/types.ts
index 3f989a78f7a3..9b5d20abc1e9 100644
--- a/utopia-remix/app/types.ts
+++ b/utopia-remix/app/types.ts
@@ -246,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
}
From 332e935c0759149b331da0462d46cce304d2a79a Mon Sep 17 00:00:00 2001
From: Federico Ruggi <1081051+ruggi@users.noreply.github.com>
Date: Tue, 2 Apr 2024 11:22:29 +0200
Subject: [PATCH 4/6] when/unless
---
utopia-remix/app/components/sharingDialog.tsx | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/utopia-remix/app/components/sharingDialog.tsx b/utopia-remix/app/components/sharingDialog.tsx
index 78c1467f6af1..2e95ae3b93ef 100644
--- a/utopia-remix/app/components/sharingDialog.tsx
+++ b/utopia-remix/app/components/sharingDialog.tsx
@@ -33,7 +33,7 @@ import { assertNever } from '../util/assertNever'
import { useCopyProjectLinkToClipboard } from '../util/copyProjectLink'
import { isLikeApiError } from '../util/errors'
import { useProjectEditorLink } from '../util/links'
-import { when } from '../util/react-conditionals'
+import { unless, when } from '../util/react-conditionals'
import { Spinner } from './spinner'
import { UserAvatar } from './userAvatar'
@@ -299,14 +299,17 @@ function AccessRequests(props: {
name={user.name ?? user.email ?? user.user_id}
isDisabled={!isCollaborative}
>
- {isCollaborative ? (
+ {when(
+ isCollaborative,
- ) : (
+ />,
+ )}
+ {unless(
+ isCollaborative,
+ ,
)}
)
From 7fa80a00d8b888ebc62e53fef90c18b1fc21eec3 Mon Sep 17 00:00:00 2001
From: Federico Ruggi <1081051+ruggi@users.noreply.github.com>
Date: Tue, 2 Apr 2024 11:34:12 +0200
Subject: [PATCH 5/6] update
---
utopia-remix/app/components/sharingDialog.tsx | 49 +++++++------------
1 file changed, 18 insertions(+), 31 deletions(-)
diff --git a/utopia-remix/app/components/sharingDialog.tsx b/utopia-remix/app/components/sharingDialog.tsx
index 2e95ae3b93ef..126eb3a65fa2 100644
--- a/utopia-remix/app/components/sharingDialog.tsx
+++ b/utopia-remix/app/components/sharingDialog.tsx
@@ -294,7 +294,7 @@ function AccessRequests(props: {
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(
@@ -379,35 +395,6 @@ const AccessRequestDropdown = React.memo(
)
AccessRequestDropdown.displayName = 'AccessRequestDropdown'
-const AccessRequestDropdownLabel = React.memo(({ status }: { status: AccessRequestStatus }) => {
- switch (status) {
- case AccessRequestStatus.PENDING:
- return (
-
- )
- case AccessRequestStatus.APPROVED:
- return (
-
- )
- case AccessRequestStatus.REJECTED:
- return (
-
- )
- default:
- assertNever(status)
- }
-})
-AccessRequestDropdownLabel.displayName = 'AccessRequestDropdownLabel'
-
const CollaboratorRow = React.memo(
({
picture,
From c23c9b5446eb5c8d0084fb008a4c585306c99a90 Mon Sep 17 00:00:00 2001
From: Federico Ruggi <1081051+ruggi@users.noreply.github.com>
Date: Tue, 2 Apr 2024 16:32:15 +0200
Subject: [PATCH 6/6] rename
---
utopia-remix/app/components/sharingDialog.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/utopia-remix/app/components/sharingDialog.tsx b/utopia-remix/app/components/sharingDialog.tsx
index 126eb3a65fa2..4c9fc293fbcb 100644
--- a/utopia-remix/app/components/sharingDialog.tsx
+++ b/utopia-remix/app/components/sharingDialog.tsx
@@ -233,12 +233,12 @@ function AccessRequests(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 [previousAccessRequests, setPreviousAccessRequests] = React.useState(props.accessRequests)
const updateAccessRequestFetcher = useFetcherWithOperation(projectId, 'updateAccessRequest')
const onUpdateAccessRequest = React.useCallback(
(token: string, action: UpdateAccessRequestAction) => () => {
- setStableAccessRequests(accessRequests)
+ setPreviousAccessRequests(accessRequests)
setAccessRequests((reqs) => {
switch (action) {
case 'destroy':
@@ -269,10 +269,10 @@ function AccessRequests(props: {
const resetAccessRequests = React.useCallback(
(data: unknown) => {
if (isLikeApiError(data)) {
- setAccessRequests(stableAccessRequests)
+ setAccessRequests(previousAccessRequests)
}
},
- [stableAccessRequests],
+ [previousAccessRequests],
)
useFetcherDataUnkown(updateAccessRequestFetcher, resetAccessRequests)