Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
})
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,9 +25,14 @@ type AlertDialogHandle = ReturnType<typeof AlertDialog.createHandle<DeleteProjec
export interface DeleteProjectDialogProps {
handle: AlertDialogHandle;
refetch: () => 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);

Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Menu.Item
leadingIcon={
isChecking ? (
<Spinner size={2} />
) : (
<Image src={deleteIcon as unknown as string} alt="Delete" width={16} height={16} />
)
}
closeOnClick={false}
disabled={isChecking}
style={{ color: 'var(--rs-color-foreground-danger-primary)' }}
{...props}
>
Delete project
</Menu.Item>
);
}
70 changes: 50 additions & 20 deletions web/sdk/client/views/projects/project-details-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -73,8 +78,6 @@ interface ProjectUserRolePair {

const memberMenuHandle = Menu.createHandle<MemberMenuPayload>();
const editProjectDialogHandle = Dialog.createHandle<EditProjectPayload>();
const deleteProjectDialogHandle =
AlertDialog.createHandle<DeleteProjectPayload>();
const removeMemberDialogHandle =
AlertDialog.createHandle<RemoveMemberPayload>();
const updateRoleDialogHandle = AlertDialog.createHandle<UpdateRolePayload>();
Expand All @@ -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<DeleteProjectPayload>()
);
const [deleteBlockedDialogHandle] = useState(() =>
AlertDialog.createHandle<DeleteBlockedPayload>()
);

const {
data: project,
Expand Down Expand Up @@ -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 || '';

Expand Down Expand Up @@ -314,11 +340,17 @@ export function ProjectDetailsView({
>
{!isLoading && (canUpdateProject || canDeleteProject) && (
<ProjectActionsMenu
menuHandle={projectActionsMenuHandle}
projectId={projectId}
projectName={projectName}
projectTitle={projectTitle}
canUpdate={canUpdateProject}
canDelete={canDeleteProject}
isCheckingDelete={isChecking}
onDelete={async () => {
await startDelete({ projectId, projectName: projectTitle });
projectActionsMenuHandle.close();
}}
Comment on lines +349 to +353

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Functional Correctness · Quick win

Supported by static analysis

Script executed:

sed -n '1,110p' web/sdk/client/hooks/useDeleteCheck.ts; rg -n -C 3 'ProjectDetailsView' web/sdk/client web/apps | head -110

Repository: raystack/frontier

Length of output: 6373


Script executed:

cd web/sdk/client && rg -n -F -e 'projectId' -e 'key=' -e 'startDelete' -e 'openWithPayload' -e 'useEffect' views/projects/project-details-view.tsx views/projects/components/delete-project-dialog.tsx views/projects/projects-view.tsx || test "$?" -eq 1
echo '---dialog---'
cat views/projects/components/delete-project-dialog.tsx
echo '---details---'
sed -n '1,140p' views/projects/project-details-view.tsx
echo '---routes---'
cd /  2>/dev/null; cd - >/dev/null
cd "$(git rev-parse --show-toplevel)" && rg -n -F -e 'ProjectDetails' -e 'ProjectDetailsView' -e 'projectId' web/apps --glob '*.tsx' --glob '*.ts' | head -60 || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 12676


Script executed:

cd "$(git rev-parse --show-toplevel)/web/sdk/client" && rg -n -F -e 'DeleteProjectDialog' -e 'DeleteBlockedDialogHost' -e 'deleteProjectDialogHandle' -e 'deleteBlockedDialogHandle' -e 'projectActionsMenuHandle' views/projects/project-details-view.tsx || test "$?" -eq 1
echo '---170-230---'
sed -n '170,230p' views/projects/project-details-view.tsx
echo '---295-365---'
sed -n '295,365p' views/projects/project-details-view.tsx
echo '---480-520---'
sed -n '476,520p' views/projects/project-details-view.tsx

Repository: raystack/frontier

Length of output: 5440


Key the details view by projectId so stale delete checks cannot reach a new project.

ProjectDetailsView keeps its state when projectId changes. If a check for project A resolves after navigation to project B, the confirm dialog opens with A's payload. Confirming from B's page deletes A. Remount the view when projectId changes, for example by keying it by projectId inside the SDK export.

/>
)}
</ViewHeader>
Expand Down Expand Up @@ -437,7 +469,9 @@ export function ProjectDetailsView({
<DeleteProjectDialog
handle={deleteProjectDialogHandle}
refetch={handleDeleteSuccess}
onDeleteError={handleDeleteError}
/>
<DeleteBlockedDialogRoot handle={deleteBlockedDialogHandle} />
<RemoveMemberDialog
handle={removeMemberDialogHandle}
refetch={refetchMembers}
Expand All @@ -452,26 +486,30 @@ export function ProjectDetailsView({
}

interface ProjectActionsMenuProps {
menuHandle: ReturnType<typeof Menu.createHandle>;
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 (
<>
<Menu.Trigger
handle={projectActionsMenuHandle}
handle={menuHandle}
render={
<IconButton
size={2}
Expand All @@ -482,7 +520,7 @@ function ProjectActionsMenu({
>
<DotsHorizontalIcon />
</Menu.Trigger>
<Menu handle={projectActionsMenuHandle} modal={false}>
<Menu handle={menuHandle} modal={false}>
<Menu.Content align="start" className={styles.menuContent}>
{canUpdate && (
<Menu.Item
Expand All @@ -500,19 +538,11 @@ function ProjectActionsMenu({
</Menu.Item>
)}
{canDelete && (
<Menu.Item
leadingIcon={<Image src={deleteIcon as unknown as string} alt="Delete" width={16} height={16} />}
onClick={() =>
deleteProjectDialogHandle.openWithPayload({
projectId,
projectName: projectTitle
})
}
<DeleteProjectMenuItem
isChecking={isCheckingDelete}
onClick={onDelete}
data-test-id="frontier-sdk-delete-project-details-btn"
style={{ color: 'var(--rs-color-foreground-danger-primary)' }}
>
Delete project
</Menu.Item>
/>
)}
</Menu.Content>
</Menu>
Expand Down
64 changes: 49 additions & 15 deletions web/sdk/client/views/projects/projects-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -42,10 +51,8 @@ const projectsFilterOptions = [
{ value: 'all-projects', label: 'All Projects' }
];

const projectMenuHandle = Menu.createHandle<ProjectMenuPayload>();
const addProjectDialogHandle = Dialog.createHandle();
const editProjectDialogHandle = Dialog.createHandle<EditProjectPayload>();
const deleteProjectDialogHandle = AlertDialog.createHandle<DeleteProjectPayload>();

export interface ProjectsViewProps {
title?: string;
Expand Down Expand Up @@ -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<ProjectMenuPayload>()
);
const [deleteProjectDialogHandle] = useState(() =>
AlertDialog.createHandle<DeleteProjectPayload>()
);
const [deleteBlockedDialogHandle] = useState(() =>
AlertDialog.createHandle<DeleteBlockedPayload>()
);
const { mutateAsync: checkProjectDelete } = useMutation(
FrontierServiceQueries.checkProjectDelete
);
const { startDelete, isChecking, handleDeleteError } = useDeleteCheck({
entity: 'project',
check: ({ projectId }) =>
checkProjectDelete(
create(CheckProjectDeleteRequestSchema, { id: projectId })
),
confirmDialogHandle: deleteProjectDialogHandle,
blockedDialogHandle: deleteBlockedDialogHandle
Comment on lines +99 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Poor tradeoff

Supported by static analysis

Script executed:

sed -n '1,110p' web/sdk/client/hooks/useDeleteCheck.ts; rg -n 'activeOrganization|<ProjectsView|ProjectsView\(' web/sdk/client web/apps | head -110

Repository: raystack/frontier

Length of output: 12444


Script executed:

sed -n '55,140p' web/sdk/client/views/projects/projects-view.tsx; echo '-----DIALOG-----'; sed -n '1,120p' web/sdk/client/views/projects/components/delete-project-dialog.tsx; echo '-----PAYLOAD TYPES-----'; rg -n -F -e 'DeleteProjectPayload' -e 'ProjectMenuPayload' -e 'DeleteBlockedPayload' -- web/sdk | head -40; echo '-----USAGES OF ProjectsView-----'; rg -n -F -e '<ProjectsView' -e 'ProjectsView' -- web/apps web/sdk | head -40

Repository: raystack/frontier

Length of output: 9697


Script executed:

sed -n '1,60p' web/apps/client-demo/src/pages/settings/Projects.tsx; echo '-----SETTINGS-----'; sed -n '1,60p' web/apps/client-demo/src/pages/Settings.tsx; echo '-----ROUTES-----'; rg -n -F -e 'Settings' -e 'orgId' -- web/apps/client-demo/src | head -40; echo '-----CONTEXT-----'; sed -n '180,240p' web/sdk/client/contexts/FrontierContext.tsx

Repository: raystack/frontier

Length of output: 9008


Script executed:

rg -n -F -e '<DeleteProjectDialog' -e 'deleteProjectDialogHandle' -e 'deleteBlockedDialogHandle' -e 'DeleteBlockedDialog' -e 'startDelete' -- web/sdk/client/views/projects/projects-view.tsx || test "$?" -eq 1; echo '-----ROUTER-----'; sed -n '1,70p' web/apps/client-demo/src/Router.tsx

Repository: raystack/frontier

Length of output: 3779


Discard late delete checks after the active organization changes.

If the active organization changes while a project delete check is pending, the late result still opens the delete confirmation in the current organization's view. The confirmation names the project from the previous organization and can delete it. Compare the organization ID when the check starts with the ID when it resolves, and skip the confirmation on a mismatch.

});

const resource = `app/organization:${organization?.id}`;
const listOfPermissionsToCheck = useMemo(
Expand Down Expand Up @@ -136,7 +167,7 @@ export function ProjectsView({
userAccessOnProject,
menuHandle: projectMenuHandle
}),
[userAccessOnProject]
[userAccessOnProject, projectMenuHandle]
);

if (hasNoProjects) {
Expand Down Expand Up @@ -303,19 +334,17 @@ export function ProjectsView({
</Menu.Submenu>
)}
{payload?.canDelete && (
<Menu.Item
leadingIcon={<Image src={deleteIcon as unknown as string} alt="Delete" width={16} height={16} />}
onClick={() =>
deleteProjectDialogHandle.openWithPayload({
<DeleteProjectMenuItem
isChecking={isChecking}
onClick={async () => {
await startDelete({
projectId: payload.projectId,
projectName: payload.title
})
}
});
projectMenuHandle.close();
}}
Comment on lines +339 to +345

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

sed -n '1,145p' web/sdk/client/views/projects/components/project-columns.tsx; sed -n '295,365p' web/sdk/client/views/projects/projects-view.tsx

Repository: raystack/frontier

Length of output: 5110


Script executed:

set -e
echo "=== projects-view.tsx outline ==="
ast-grep outline web/sdk/client/views/projects/projects-view.tsx --view expanded || true
echo "=== projects-view.tsx lines 1-120 ==="
nl -ba web/sdk/client/views/projects/projects-view.tsx | sed -n '1,120p'
echo "=== projects-view.tsx lines 120-300 ==="
nl -ba web/sdk/client/views/projects/projects-view.tsx | sed -n '120,300p'
echo "=== delete-project-menu-item.tsx ==="
nl -ba web/sdk/client/views/projects/components/delete-project-menu-item.tsx
echo "=== useDeleteCheck references ==="
rg -n -F --glob '*.ts*' -e 'useDeleteCheck' -- web/sdk || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 14526


Script executed:

set -e
nl -ba web/sdk/client/hooks/useDeleteCheck.ts

Repository: raystack/frontier

Length of output: 3046


Close the project menu only when it still shows the project whose delete check finished.

The delete action calls projectMenuHandle.close() after startDelete resolves. The handle is shared by all rows, so this closes whichever project menu is open at that moment. If a user closes the menu for project A during its check and opens project B's menu, A's check completion closes B's menu. The same shared isChecking state also disables B's delete item while A is being checked.

data-test-id="delete-project-dropdown-item"
style={{ color: 'var(--rs-color-foreground-danger-primary)' }}
>
Delete project
</Menu.Item>
/>
)}
</Menu.Content>
);
Expand All @@ -324,7 +353,12 @@ export function ProjectsView({

<AddProjectDialog handle={addProjectDialogHandle} refetch={refetch} />
<EditProjectDialog handle={editProjectDialogHandle} refetch={refetch} />
<DeleteProjectDialog handle={deleteProjectDialogHandle} refetch={refetch} />
<DeleteProjectDialog
handle={deleteProjectDialogHandle}
refetch={refetch}
onDeleteError={handleDeleteError}
/>
<DeleteBlockedDialogRoot handle={deleteBlockedDialogHandle} />
</ViewContainer>
);
}
2 changes: 1 addition & 1 deletion web/sdk/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export interface DeleteBlocker {
}

export type DeleteBlockedPayload = {
entity: 'organization';
entity: 'organization' | 'project';
blockers: DeleteBlocker[];
};

Expand Down
Loading