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/components/delete-project-menu-item.tsx b/web/sdk/client/views/projects/components/delete-project-menu-item.tsx new file mode 100644 index 0000000000..b194fc7b8e --- /dev/null +++ b/web/sdk/client/views/projects/components/delete-project-menu-item.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { Image, Menu, Spinner } from '@raystack/apsara'; +import deleteIcon from '../../../assets/delete.svg'; + +export interface DeleteProjectMenuItemProps { + isChecking: boolean; + onClick: () => void; + 'data-test-id': string; +} + +export function DeleteProjectMenuItem({ + isChecking, + ...props +}: DeleteProjectMenuItemProps) { + return ( + + ) : ( + Delete + ) + } + closeOnClick={false} + disabled={isChecking} + style={{ color: 'var(--rs-color-foreground-danger-primary)' }} + {...props} + > + Delete project + + ); +} diff --git a/web/sdk/client/views/projects/project-details-view.tsx b/web/sdk/client/views/projects/project-details-view.tsx index ffad9ef2e4..372f272203 100644 --- a/web/sdk/client/views/projects/project-details-view.tsx +++ b/web/sdk/client/views/projects/project-details-view.tsx @@ -22,8 +22,9 @@ import { } 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 +35,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 @@ -45,6 +49,7 @@ import { DeleteProjectDialog, type DeleteProjectPayload } from './components/delete-project-dialog'; +import { DeleteProjectMenuItem } from './components/delete-project-menu-item'; import { RemoveMemberDialog, type RemoveMemberPayload @@ -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(); + }} /> )} @@ -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 && ( )} {canDelete && ( - } - onClick={() => - deleteProjectDialogHandle.openWithPayload({ - projectId, - projectName: projectTitle - }) - } + - Delete project - + /> )} diff --git a/web/sdk/client/views/projects/projects-view.tsx b/web/sdk/client/views/projects/projects-view.tsx index 6f3bfae5f5..47d4a3d046 100644 --- a/web/sdk/client/views/projects/projects-view.tsx +++ b/web/sdk/client/views/projects/projects-view.tsx @@ -19,20 +19,29 @@ import { Menu, Select } from '@raystack/apsara'; -import deleteIcon from '../../assets/delete.svg'; +import { create } from '@bufbuild/protobuf'; +import { useMutation } from '@connectrpc/connect-query'; +import { + CheckProjectDeleteRequestSchema, + FrontierServiceQueries +} from '@raystack/proton/frontier'; 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'; import { DeleteProjectDialog, type DeleteProjectPayload } from './components/delete-project-dialog'; +import { DeleteProjectMenuItem } from './components/delete-project-menu-item'; import { AddMemberMenuContent } from './components/add-member-menu'; import styles from './projects-view.module.css'; import { useTerminology } from '~/client/hooks/useTerminology'; @@ -42,10 +51,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 +81,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 +167,7 @@ export function ProjectsView({ userAccessOnProject, menuHandle: projectMenuHandle }), - [userAccessOnProject] + [userAccessOnProject, projectMenuHandle] ); if (hasNoProjects) { @@ -303,19 +334,17 @@ export function ProjectsView({ )} {payload?.canDelete && ( - } - onClick={() => - deleteProjectDialogHandle.openWithPayload({ + { + 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)' }} - > - Delete project - + /> )} ); @@ -324,7 +353,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[]; };