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)