diff --git a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx index 89781913b6..213f717dd1 100644 --- a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx +++ b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx @@ -23,6 +23,10 @@ const DEFAULT_COPY: Record< organization: entityLabel => ({ title: `Unable to delete ${entityLabel}`, description: `Please resolve the following issues before deleting this ${entityLabel}:` + }), + project: entityLabel => ({ + title: `Unable to delete ${entityLabel}`, + description: `This ${entityLabel} cannot be deleted because it contains resources that need to be addressed first.` }) }; diff --git a/web/sdk/client/views/projects/components/delete-project-dialog.tsx b/web/sdk/client/views/projects/components/delete-project-dialog.tsx index 11aaa52ca1..65152cce7e 100644 --- a/web/sdk/client/views/projects/components/delete-project-dialog.tsx +++ b/web/sdk/client/views/projects/components/delete-project-dialog.tsx @@ -14,7 +14,6 @@ import { DeleteProjectRequestSchema } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; -import { handleConnectError } from '~/utils/error'; export interface DeleteProjectPayload { projectId: string; @@ -26,9 +25,14 @@ type AlertDialogHandle = ReturnType void; + onDeleteError: (error: unknown) => void; } -export function DeleteProjectDialog({ handle, refetch }: DeleteProjectDialogProps) { +export function DeleteProjectDialog({ + handle, + refetch, + onDeleteError +}: DeleteProjectDialogProps) { const { activeOrganization: organization } = useFrontier(); const [isLoading, setIsLoading] = useState(false); @@ -47,11 +51,7 @@ export function DeleteProjectDialog({ handle, refetch }: DeleteProjectDialogProp refetch(); handle.close(); } catch (error) { - handleConnectError(error, { - PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", type: 'error' }), - NotFound: (err) => toastManager.add({ title: 'Not found', description: err.message, type: 'error' }), - Default: (err) => toastManager.add({ title: 'Something went wrong', description: err.message, type: 'error' }), - }); + onDeleteError(error); } finally { setIsLoading(false); } diff --git a/web/sdk/client/views/projects/project-details-view.tsx b/web/sdk/client/views/projects/project-details-view.tsx index ffad9ef2e4..ce41fd2f61 100644 --- a/web/sdk/client/views/projects/project-details-view.tsx +++ b/web/sdk/client/views/projects/project-details-view.tsx @@ -18,12 +18,14 @@ import { Dialog, IconButton, Image, - Select + Select, + Spinner } from '@raystack/apsara'; import deleteIcon from '../../assets/delete.svg'; import { toastManager } from '@raystack/apsara'; -import { useQuery } from '@connectrpc/connect-query'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; import { + CheckProjectDeleteRequestSchema, FrontierServiceQueries, ListProjectGroupsRequestSchema, ListProjectUsersRequestSchema, @@ -34,9 +36,12 @@ import { import { create } from '@bufbuild/protobuf'; import { useFrontier } from '../../contexts/FrontierContext'; import { usePermissions } from '../../hooks/usePermissions'; +import { useDeleteCheck } from '../../hooks/useDeleteCheck'; import { PERMISSIONS, shouldShowComponent } from '../../../utils'; import { ViewContainer } from '../../components/view-container'; import { ViewHeader } from '../../components/view-header'; +import { DeleteBlockedDialogRoot } from '../../components/delete-blocked-dialog'; +import type { DeleteBlockedPayload } from '../../../shared/types'; import { EditProjectDialog, type EditProjectPayload @@ -73,8 +78,6 @@ interface ProjectUserRolePair { const memberMenuHandle = Menu.createHandle(); const editProjectDialogHandle = Dialog.createHandle(); -const deleteProjectDialogHandle = - AlertDialog.createHandle(); const removeMemberDialogHandle = AlertDialog.createHandle(); const updateRoleDialogHandle = AlertDialog.createHandle(); @@ -93,6 +96,16 @@ export function ProjectDetailsView({ onDeleteSuccess }: ProjectDetailsViewProps) { const { activeOrganization: organization } = useFrontier(); + // Created per mount: a module-level handle keeps a dialog open across + // remounts (https://github.com/mui/base-ui/issues/4951). + // TODO @rohanchkrabrty: This is fixed in Base UI v1.7.0. We can update this after Apsara updates + const [projectActionsMenuHandle] = useState(() => Menu.createHandle()); + const [deleteProjectDialogHandle] = useState(() => + AlertDialog.createHandle() + ); + const [deleteBlockedDialogHandle] = useState(() => + AlertDialog.createHandle() + ); const { data: project, @@ -287,6 +300,19 @@ export function ProjectDetailsView({ onDeleteSuccess?.(); }, [onDeleteSuccess]); + const { mutateAsync: checkProjectDelete } = useMutation( + FrontierServiceQueries.checkProjectDelete + ); + const { startDelete, isChecking, handleDeleteError } = useDeleteCheck({ + entity: 'project', + check: ({ projectId }) => + checkProjectDelete( + create(CheckProjectDeleteRequestSchema, { id: projectId }) + ), + confirmDialogHandle: deleteProjectDialogHandle, + blockedDialogHandle: deleteBlockedDialogHandle + }); + const projectTitle = project?.title || ''; const projectName = project?.name || ''; @@ -314,11 +340,17 @@ export function ProjectDetailsView({ > {!isLoading && (canUpdateProject || canDeleteProject) && ( { + await startDelete({ projectId, projectName: projectTitle }); + projectActionsMenuHandle.close(); + }} /> )} @@ -409,7 +441,7 @@ export function ProjectDetailsView({ ))} } + leadingIcon={} onClick={() => payload && removeMemberDialogHandle.openWithPayload({ @@ -437,7 +469,9 @@ export function ProjectDetailsView({ + ; projectId: string; projectName: string; projectTitle: string; canUpdate: boolean; canDelete: boolean; + isCheckingDelete: boolean; + onDelete: () => void; } -const projectActionsMenuHandle = Menu.createHandle(); - function ProjectActionsMenu({ + menuHandle, projectId, projectName, projectTitle, canUpdate, - canDelete + canDelete, + isCheckingDelete, + onDelete }: ProjectActionsMenuProps) { return ( <> - + {canUpdate && ( } - onClick={() => - deleteProjectDialogHandle.openWithPayload({ - projectId, - projectName: projectTitle - }) + leadingIcon={ + isCheckingDelete ? ( + + ) : ( + + ) } + closeOnClick={false} + disabled={isCheckingDelete} + onClick={onDelete} data-test-id="frontier-sdk-delete-project-details-btn" style={{ color: 'var(--rs-color-foreground-danger-primary)' }} > diff --git a/web/sdk/client/views/projects/projects-view.tsx b/web/sdk/client/views/projects/projects-view.tsx index 6f3bfae5f5..b880f31b4a 100644 --- a/web/sdk/client/views/projects/projects-view.tsx +++ b/web/sdk/client/views/projects/projects-view.tsx @@ -17,18 +17,28 @@ import { AlertDialog, Image, Menu, - Select + Select, + Spinner } from '@raystack/apsara'; +import { create } from '@bufbuild/protobuf'; +import { useMutation } from '@connectrpc/connect-query'; +import { + CheckProjectDeleteRequestSchema, + FrontierServiceQueries +} from '@raystack/proton/frontier'; import deleteIcon from '../../assets/delete.svg'; import inboxStackIcon from '../../assets/inbox-stack.svg'; import { toastManager } from '@raystack/apsara'; import { useFrontier } from '../../contexts/FrontierContext'; import { useOrganizationProjects } from '../../hooks/useOrganizationProjects'; import { usePermissions } from '../../hooks/usePermissions'; +import { useDeleteCheck } from '../../hooks/useDeleteCheck'; import { AuthTooltipMessage } from '../../utils'; import { PERMISSIONS, shouldShowComponent } from '../../../utils'; import { ViewContainer } from '../../components/view-container'; import { ViewHeader } from '../../components/view-header'; +import { DeleteBlockedDialogRoot } from '../../components/delete-blocked-dialog'; +import type { DeleteBlockedPayload } from '../../../shared/types'; import { getColumns, type ProjectMenuPayload } from './components/project-columns'; import { AddProjectDialog } from './components/add-project-dialog'; import { EditProjectDialog, type EditProjectPayload } from './components/edit-project-dialog'; @@ -42,10 +52,8 @@ const projectsFilterOptions = [ { value: 'all-projects', label: 'All Projects' } ]; -const projectMenuHandle = Menu.createHandle(); const addProjectDialogHandle = Dialog.createHandle(); const editProjectDialogHandle = Dialog.createHandle(); -const deleteProjectDialogHandle = AlertDialog.createHandle(); export interface ProjectsViewProps { title?: string; @@ -74,6 +82,30 @@ export function ProjectsView({ const { activeOrganization: organization } = useFrontier(); const t = useTerminology(); + // Created per mount: a module-level handle keeps a dialog open across + // remounts (https://github.com/mui/base-ui/issues/4951). + // TODO @rohanchkrabrty: This is fixed in Base UI v1.7.0. We can update this after Apsara updates + const [projectMenuHandle] = useState(() => + Menu.createHandle() + ); + const [deleteProjectDialogHandle] = useState(() => + AlertDialog.createHandle() + ); + const [deleteBlockedDialogHandle] = useState(() => + AlertDialog.createHandle() + ); + const { mutateAsync: checkProjectDelete } = useMutation( + FrontierServiceQueries.checkProjectDelete + ); + const { startDelete, isChecking, handleDeleteError } = useDeleteCheck({ + entity: 'project', + check: ({ projectId }) => + checkProjectDelete( + create(CheckProjectDeleteRequestSchema, { id: projectId }) + ), + confirmDialogHandle: deleteProjectDialogHandle, + blockedDialogHandle: deleteBlockedDialogHandle + }); const resource = `app/organization:${organization?.id}`; const listOfPermissionsToCheck = useMemo( @@ -136,7 +168,7 @@ export function ProjectsView({ userAccessOnProject, menuHandle: projectMenuHandle }), - [userAccessOnProject] + [userAccessOnProject, projectMenuHandle] ); if (hasNoProjects) { @@ -304,13 +336,22 @@ export function ProjectsView({ )} {payload?.canDelete && ( } - onClick={() => - deleteProjectDialogHandle.openWithPayload({ + leadingIcon={ + isChecking ? ( + + ) : ( + + ) + } + closeOnClick={false} + disabled={isChecking} + onClick={async () => { + await startDelete({ projectId: payload.projectId, projectName: payload.title - }) - } + }); + projectMenuHandle.close(); + }} data-test-id="delete-project-dropdown-item" style={{ color: 'var(--rs-color-foreground-danger-primary)' }} > @@ -324,7 +365,12 @@ export function ProjectsView({ - + + ); } diff --git a/web/sdk/shared/types.ts b/web/sdk/shared/types.ts index e190512ada..bcb28924e4 100644 --- a/web/sdk/shared/types.ts +++ b/web/sdk/shared/types.ts @@ -73,7 +73,7 @@ export interface DeleteBlocker { } export type DeleteBlockedPayload = { - entity: 'organization'; + entity: 'organization' | 'project'; blockers: DeleteBlocker[]; };