{
+ const entityKind = entity.kind.toLowerCase();
+ const entityName = entity.metadata.name;
+
+ if (entityKind === 'component') {
+ await client.deleteComponent(entity);
+ return;
+ }
+ if (entityKind === 'system') {
+ await client.deleteProject(entity);
+ return;
+ }
+ if (entityKind === 'domain') {
+ await client.deleteNamespace(entity);
+ return;
+ }
+ if (isSupportedKind(entityKind)) {
+ const apiKind = mapKindToApiKind(entityKind);
+ const namespace =
+ entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE];
+
+ if (!CLUSTER_SCOPED_RESOURCE_KINDS.has(apiKind) && !namespace) {
+ throw new Error(
+ `Missing namespace annotation for ${getEntityDisplayType(
+ entityKind,
+ ).toLowerCase()} "${entityName}"`,
+ );
+ }
+
+ await client.deleteResourceDefinition(apiKind, namespace ?? '', entityName);
+ return;
+ }
+ throw new Error(`Unsupported entity kind for deletion: ${entity.kind}`);
+}
+
+/**
+ * Extra warning line for deletes that cascade to child entities, shown inside
+ * the confirmation dialog. Undefined for kinds without a cascade.
+ */
+export function getEntityDeleteCascadeNote(kind: string): ReactNode {
+ const kindLower = kind.toLowerCase();
+ if (kindLower === 'system') {
+ return (
+
+ Note: All components within this project will also be deleted.
+
+ );
+ }
+ if (kindLower === 'domain') {
+ return (
+
+ Note: All projects and components within this namespace will also be
+ deleted.
+
+ );
+ }
+ return undefined;
+}
diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts b/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts
index 5f31ca0a9..402a9039f 100644
--- a/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts
+++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts
@@ -2,4 +2,14 @@ export {
useDeleteEntityMenuItems,
type DeletePermissionInfo,
} from './useDeleteEntityMenuItems';
+export {
+ useDeleteEntityDialog,
+ type UseDeleteEntityDialogOptions,
+ type UseDeleteEntityDialogResult,
+} from './useDeleteEntityDialog';
+export { usePendingDeletionOverlay } from './usePendingDeletionOverlay';
+export {
+ getEntityDisplayType,
+ isDeletableEntityKind,
+} from './deleteEntityDispatch';
export { useEntityExistsCheck } from './useEntityExistsCheck';
diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.test.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.test.tsx
new file mode 100644
index 000000000..35b922f11
--- /dev/null
+++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.test.tsx
@@ -0,0 +1,263 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { TestApiProvider } from '@backstage/test-utils';
+import { alertApiRef } from '@backstage/core-plugin-api';
+import { createMockOpenChoreoClient } from '@openchoreo/test-utils';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+import { useDeleteEntityDialog } from './useDeleteEntityDialog';
+import type { Entity } from '@backstage/catalog-model';
+
+// ---- Mocks ----
+
+jest.mock('../../../utils/errorUtils', () => ({
+ isForbiddenError: (err: any) => err?.message?.includes('403'),
+ getErrorMessage: (err: any) =>
+ err instanceof Error ? err.message : String(err),
+}));
+
+// ---- Helpers ----
+
+const mockClient = createMockOpenChoreoClient();
+const mockAlertApi = { post: jest.fn() };
+
+function makeEntity(kind: string, name: string): Entity {
+ return {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind,
+ metadata: {
+ name,
+ namespace: 'default',
+ annotations: { 'openchoreo.io/namespace': 'test-ns' },
+ },
+ spec: {},
+ };
+}
+
+const makeComponent = (name: string) => makeEntity('Component', name);
+
+/** Renders the hook: a button per entity opens the shared dialog. */
+function TestHarness({
+ entities,
+ onDeleted,
+}: {
+ entities: Entity[];
+ onDeleted?: () => void;
+}) {
+ const { requestDelete, DeleteDialog } = useDeleteEntityDialog({
+ onDeleted,
+ });
+
+ return (
+
+ {entities.map(entity => (
+
+ ))}
+
+
+ );
+}
+
+function renderHarness(entities: Entity[], onDeleted?: () => void) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+// ---- Tests ----
+
+describe('useDeleteEntityDialog', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('dialog stays closed until a delete is requested', () => {
+ renderHarness([makeComponent('my-service')]);
+
+ expect(
+ screen.queryByRole('heading', { name: /delete component/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('opens the confirmation dialog for the requested component', async () => {
+ const user = userEvent.setup();
+ renderHarness([makeComponent('my-service')]);
+
+ await user.click(screen.getByTestId('open-my-service'));
+
+ expect(
+ screen.getByRole('heading', { name: /delete component/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/my-service/)).toBeInTheDocument();
+ });
+
+ it('deletes the component, alerts success and calls onDeleted', async () => {
+ const user = userEvent.setup();
+ const onDeleted = jest.fn();
+ mockClient.deleteComponent.mockResolvedValue(undefined);
+
+ renderHarness([makeComponent('my-service')], onDeleted);
+
+ await user.click(screen.getByTestId('open-my-service'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(mockClient.deleteComponent).toHaveBeenCalledTimes(1);
+ expect(mockAlertApi.post).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Component "my-service" has been marked for deletion',
+ severity: 'success',
+ }),
+ );
+ expect(onDeleted).toHaveBeenCalledTimes(1);
+ });
+ // Dialog closes on success.
+ expect(
+ screen.queryByRole('heading', { name: /delete component/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('deletes a project (system) with the cascade note shown', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteProject.mockResolvedValue(undefined);
+
+ renderHarness([makeEntity('System', 'my-project')]);
+
+ await user.click(screen.getByTestId('open-my-project'));
+
+ expect(
+ screen.getByRole('heading', { name: /delete project/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/All components within this project will also be/i),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(mockClient.deleteProject).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({ name: 'my-project' }),
+ }),
+ );
+ expect(mockAlertApi.post).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Project "my-project" has been marked for deletion',
+ severity: 'success',
+ }),
+ );
+ });
+ });
+
+ it('deletes a namespace (domain) via deleteNamespace', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteNamespace.mockResolvedValue(undefined);
+
+ renderHarness([makeEntity('Domain', 'my-ns')]);
+
+ await user.click(screen.getByTestId('open-my-ns'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(mockClient.deleteNamespace).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('deletes a platform resource kind via deleteResourceDefinition', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteResourceDefinition.mockResolvedValue(undefined);
+
+ renderHarness([makeEntity('Resource', 'my-db')]);
+
+ await user.click(screen.getByTestId('open-my-db'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(mockClient.deleteResourceDefinition).toHaveBeenCalledWith(
+ 'resources',
+ 'test-ns',
+ 'my-db',
+ );
+ });
+ });
+
+ it('targets the entity whose row requested deletion', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteComponent.mockResolvedValue(undefined);
+
+ renderHarness([makeComponent('svc-a'), makeComponent('svc-b')]);
+
+ await user.click(screen.getByTestId('open-svc-b'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(mockClient.deleteComponent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({ name: 'svc-b' }),
+ }),
+ );
+ });
+ });
+
+ it('shows a permission error on 403 and does not call onDeleted', async () => {
+ const user = userEvent.setup();
+ const onDeleted = jest.fn();
+ mockClient.deleteComponent.mockRejectedValue(new Error('403 Forbidden'));
+
+ renderHarness([makeComponent('my-service')], onDeleted);
+
+ await user.click(screen.getByTestId('open-my-service'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/You do not have permission to delete this component/),
+ ).toBeInTheDocument();
+ });
+ expect(onDeleted).not.toHaveBeenCalled();
+ });
+
+ it('shows a generic error on non-403 failure', async () => {
+ const user = userEvent.setup();
+ mockClient.deleteComponent.mockRejectedValue(new Error('Network timeout'));
+
+ renderHarness([makeComponent('my-service')]);
+
+ await user.click(screen.getByTestId('open-my-service'));
+ await user.click(screen.getByRole('button', { name: /^delete$/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Network timeout/)).toBeInTheDocument();
+ });
+ });
+
+ it('closes on cancel without calling the API', async () => {
+ const user = userEvent.setup();
+ renderHarness([makeComponent('my-service')]);
+
+ await user.click(screen.getByTestId('open-my-service'));
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('heading', { name: /delete component/i }),
+ ).not.toBeInTheDocument();
+ });
+ expect(mockClient.deleteComponent).not.toHaveBeenCalled();
+ });
+});
diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.tsx
new file mode 100644
index 000000000..597ab58cb
--- /dev/null
+++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.tsx
@@ -0,0 +1,116 @@
+import { useCallback, useState } from 'react';
+import { useApi, alertApiRef } from '@backstage/core-plugin-api';
+import { Entity } from '@backstage/catalog-model';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
+import { isForbiddenError, getErrorMessage } from '../../../utils/errorUtils';
+import { DeleteEntityDialog } from '../components';
+import {
+ getEntityDeleteCascadeNote,
+ getEntityDisplayType,
+ performEntityDelete,
+} from './deleteEntityDispatch';
+
+export interface UseDeleteEntityDialogOptions {
+ /**
+ * Called after an entity has been successfully marked for deletion. Use it
+ * to refresh the surrounding list (the deleted row stays visible with a
+ * "marked for deletion" badge until the next catalog sync removes it).
+ */
+ onDeleted?: (entity: Entity) => void;
+}
+
+export interface UseDeleteEntityDialogResult {
+ /** Open the confirmation dialog for the given entity. */
+ requestDelete: (entity: Entity) => void;
+ /** Render once in the surrounding component; controlled internally. */
+ DeleteDialog: React.FC;
+}
+
+/**
+ * Delete an entity from a listing (the catalog "All ..." pages, the project's
+ * Project Contents table, the namespace projects/resources cards) without
+ * leaving the page.
+ *
+ * Unlike {@link useDeleteEntityMenuItems} — which is bound to a single entity
+ * via EntityLayout's context menu and navigates to `/catalog` on success — this
+ * hook is list-oriented: a single dialog instance is reused across rows
+ * (`requestDelete(entity)` targets one), and on success it calls `onDeleted`
+ * (typically an optimistic mark + refetch) instead of navigating away. It
+ * supports every kind {@link performEntityDelete} can dispatch: components,
+ * projects, namespaces and the platform resource kinds.
+ */
+export function useDeleteEntityDialog(
+ options?: UseDeleteEntityDialogOptions,
+): UseDeleteEntityDialogResult {
+ const { onDeleted } = options ?? {};
+ const openChoreoClient = useApi(openChoreoClientApiRef);
+ const alertApi = useApi(alertApiRef);
+
+ const [target, setTarget] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const requestDelete = useCallback((entity: Entity) => {
+ setTarget(entity);
+ setError(null);
+ }, []);
+
+ const handleClose = useCallback(() => {
+ if (deleting) {
+ return;
+ }
+ setTarget(null);
+ setError(null);
+ }, [deleting]);
+
+ const handleConfirm = useCallback(async () => {
+ if (!target) {
+ return;
+ }
+ const entityName = target.metadata.name;
+ const displayType = getEntityDisplayType(target.kind);
+ setDeleting(true);
+ setError(null);
+
+ try {
+ await performEntityDelete(openChoreoClient, target);
+
+ alertApi.post({
+ message: `${displayType} "${entityName}" has been marked for deletion`,
+ severity: 'success',
+ display: 'transient',
+ });
+
+ setTarget(null);
+ onDeleted?.(target);
+ } catch (err) {
+ const errorMessage = isForbiddenError(err)
+ ? `You do not have permission to delete this ${displayType.toLowerCase()}. Contact your administrator.`
+ : getErrorMessage(err);
+ setError(errorMessage);
+ alertApi.post({ message: errorMessage, severity: 'error' });
+ } finally {
+ setDeleting(false);
+ }
+ }, [target, openChoreoClient, alertApi, onDeleted]);
+
+ const DeleteDialog: React.FC = useCallback(
+ () => (
+
+ ),
+ [target, deleting, error, handleClose, handleConfirm],
+ );
+
+ return { requestDelete, DeleteDialog };
+}
diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx
index cb3440e59..8e8407357 100644
--- a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx
+++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx
@@ -1,29 +1,18 @@
import { useState, useCallback, useMemo } from 'react';
-import {
- Dialog,
- DialogTitle,
- DialogContent,
- DialogActions,
- Button,
- Typography,
- CircularProgress,
-} from '@material-ui/core';
import DeleteIcon from '@material-ui/icons/Delete';
import { useNavigate } from 'react-router-dom';
import { useApi, alertApiRef, IconComponent } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
-import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common';
-import {
- openChoreoClientApiRef,
- CLUSTER_SCOPED_RESOURCE_KINDS,
-} from '../../../api/OpenChoreoClientApi';
+import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi';
import { isForbiddenError, getErrorMessage } from '../../../utils/errorUtils';
-import { useStyles } from '../styles';
import { isMarkedForDeletion } from '../utils';
+import { DeleteEntityDialog } from '../components';
import {
- isSupportedKind,
- mapKindToApiKind,
-} from '../../ResourceDefinition/utils';
+ getEntityDeleteCascadeNote,
+ getEntityDisplayType,
+ isDeletableEntityKind,
+ performEntityDelete,
+} from './deleteEntityDispatch';
interface ExtraContextMenuItem {
title: string;
@@ -44,34 +33,6 @@ export interface DeletePermissionInfo {
deniedTooltip: string;
}
-/** Human-friendly display names for all deletable entity kinds */
-const KIND_DISPLAY_NAMES: Record = {
- component: 'Component',
- resource: 'Resource',
- system: 'Project',
- domain: 'Namespace',
- environment: 'Environment',
- observabilityalertsnotificationchannel: 'Notification Channel',
- dataplane: 'Dataplane',
- clusterdataplane: 'Cluster Data Plane',
- buildplane: 'Build Plane',
- clusterbuildplane: 'Cluster Build Plane',
- workflowplane: 'Workflow Plane',
- clusterworkflowplane: 'Cluster Workflow Plane',
- observabilityplane: 'Observability Plane',
- clusterobservabilityplane: 'Cluster Observability Plane',
- deploymentpipeline: 'Deployment Pipeline',
- componenttype: 'Component Type',
- resourcetype: 'Resource Type',
- clustercomponenttype: 'Cluster Component Type',
- clusterresourcetype: 'Cluster Resource Type',
- traittype: 'Trait Type',
- clustertraittype: 'Cluster Trait Type',
- workflow: 'Workflow',
- clusterworkflow: 'Cluster Workflow',
- componentworkflow: 'Component Workflow',
-};
-
/**
* Hook that provides delete menu items for EntityLayout's extraContextMenuItems.
*
@@ -91,21 +52,14 @@ export function useDeleteEntityMenuItems(
const openChoreoClient = useApi(openChoreoClientApiRef);
const alertApi = useApi(alertApiRef);
const navigate = useNavigate();
- const classes = useStyles();
const entityKind = entity.kind.toLowerCase();
const entityName = entity.metadata.name;
- const isComponent = entityKind === 'component';
- const isProject = entityKind === 'system';
- const isDomain = entityKind === 'domain';
- const isPlatformResource = isSupportedKind(entityKind);
-
- const entityDisplayType = KIND_DISPLAY_NAMES[entityKind] ?? entityKind;
+ const entityDisplayType = getEntityDisplayType(entityKind);
const alreadyMarkedForDeletion = isMarkedForDeletion(entity);
- const isDeletableKind =
- isComponent || isProject || isDomain || isPlatformResource;
- const canDelete = isDeletableKind && !alreadyMarkedForDeletion;
+ const canDelete =
+ isDeletableEntityKind(entityKind) && !alreadyMarkedForDeletion;
const handleOpenDialog = useCallback(() => {
setDialogOpen(true);
@@ -124,31 +78,7 @@ export function useDeleteEntityMenuItems(
setError(null);
try {
- if (isComponent) {
- await openChoreoClient.deleteComponent(entity);
- } else if (isProject) {
- await openChoreoClient.deleteProject(entity);
- } else if (isDomain) {
- await openChoreoClient.deleteNamespace(entity);
- } else if (isPlatformResource) {
- const apiKind = mapKindToApiKind(entityKind);
- const namespace =
- entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE];
-
- if (!CLUSTER_SCOPED_RESOURCE_KINDS.has(apiKind) && !namespace) {
- throw new Error(
- `Missing namespace annotation for ${entityDisplayType.toLowerCase()} "${entityName}"`,
- );
- }
-
- await openChoreoClient.deleteResourceDefinition(
- apiKind,
- namespace ?? '',
- entityName,
- );
- } else {
- throw new Error(`Unsupported entity kind for deletion: ${entityKind}`);
- }
+ await performEntityDelete(openChoreoClient, entity);
alertApi.post({
message: `${entityDisplayType} "${entityName}" has been marked for deletion`,
@@ -172,13 +102,8 @@ export function useDeleteEntityMenuItems(
}
}, [
entity,
- entityKind,
entityName,
entityDisplayType,
- isComponent,
- isProject,
- isDomain,
- isPlatformResource,
openChoreoClient,
alertApi,
navigate,
@@ -216,82 +141,31 @@ export function useDeleteEntityMenuItems(
];
}, [canDelete, entityDisplayType, handleOpenDialog, deletePermission]);
+ const cascadeNote = useMemo(
+ () => getEntityDeleteCascadeNote(entityKind),
+ [entityKind],
+ );
+
const DeleteConfirmationDialog: React.FC = useCallback(
() => (
-
+ onConfirm={handleConfirmDelete}
+ />
),
[
dialogOpen,
handleCloseDialog,
handleConfirmDelete,
- classes,
entityDisplayType,
entityName,
- isProject,
- isDomain,
+ cascadeNote,
error,
deleting,
],
diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/usePendingDeletionOverlay.ts b/plugins/openchoreo/src/components/DeleteEntity/hooks/usePendingDeletionOverlay.ts
new file mode 100644
index 000000000..38891e3ce
--- /dev/null
+++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/usePendingDeletionOverlay.ts
@@ -0,0 +1,52 @@
+import { useCallback, useMemo, useState } from 'react';
+import { Entity } from '@backstage/catalog-model';
+import {
+ entityDeletionKey,
+ markEntityForDeletionLocally,
+} from '../utils/deletionUtils';
+
+export interface UsePendingDeletionOverlayResult {
+ /** Record that this entity was deleted from the listing this session. */
+ markDeleted: (entity: Entity) => void;
+ /** Re-map a fetched entity list, marking pending deletions. */
+ overlay: (entities: Entity[]) => Entity[];
+}
+
+/**
+ * Tracks entities deleted from a listing this session and overlays the
+ * "marked for deletion" annotation on top of fetched rows until the catalog
+ * catches up.
+ *
+ * A delete only updates the control plane; the catalog entity (and therefore
+ * the listing row) lags behind by a sync/event, so without this the row would
+ * briefly look alive — and clickable — after a confirmed delete. Pending
+ * deletions are keyed by entity identity, not object reference, so overlaying
+ * survives refetches.
+ */
+export function usePendingDeletionOverlay(): UsePendingDeletionOverlayResult {
+ const [pendingDeletions, setPendingDeletions] = useState>(
+ () => new Set(),
+ );
+
+ const markDeleted = useCallback((entity: Entity) => {
+ setPendingDeletions(prev => {
+ const next = new Set(prev);
+ next.add(entityDeletionKey(entity));
+ return next;
+ });
+ }, []);
+
+ const overlay = useCallback(
+ (entities: Entity[]) =>
+ pendingDeletions.size === 0
+ ? entities
+ : entities.map(entity =>
+ pendingDeletions.has(entityDeletionKey(entity))
+ ? markEntityForDeletionLocally(entity)
+ : entity,
+ ),
+ [pendingDeletions],
+ );
+
+ return useMemo(() => ({ markDeleted, overlay }), [markDeleted, overlay]);
+}
diff --git a/plugins/openchoreo/src/components/DeleteEntity/index.ts b/plugins/openchoreo/src/components/DeleteEntity/index.ts
index 809747973..d950493d2 100644
--- a/plugins/openchoreo/src/components/DeleteEntity/index.ts
+++ b/plugins/openchoreo/src/components/DeleteEntity/index.ts
@@ -1,15 +1,35 @@
// Hooks
export {
useDeleteEntityMenuItems,
+ useDeleteEntityDialog,
+ usePendingDeletionOverlay,
useEntityExistsCheck,
+ getEntityDisplayType,
+ isDeletableEntityKind,
type DeletePermissionInfo,
+ type UseDeleteEntityDialogOptions,
+ type UseDeleteEntityDialogResult,
} from './hooks';
// Components
-export { DeletionBadge, DeletionWarning } from './components';
+export {
+ DeletionBadge,
+ DeletionWarning,
+ DeleteEntityDialog,
+ RowDeleteButton,
+} from './components';
+export type {
+ DeleteEntityDialogProps,
+ RowDeleteButtonProps,
+} from './components';
// Utils
-export { isMarkedForDeletion, getDeletionTimestamp } from './utils';
+export {
+ isMarkedForDeletion,
+ getDeletionTimestamp,
+ entityDeletionKey,
+ markEntityForDeletionLocally,
+} from './utils';
// Types
export type { EntityStatus, EntityExistsCheckResult } from './types';
diff --git a/plugins/openchoreo/src/components/DeleteEntity/utils/deletionUtils.ts b/plugins/openchoreo/src/components/DeleteEntity/utils/deletionUtils.ts
index bf9d38d92..43ae1d880 100644
--- a/plugins/openchoreo/src/components/DeleteEntity/utils/deletionUtils.ts
+++ b/plugins/openchoreo/src/components/DeleteEntity/utils/deletionUtils.ts
@@ -15,3 +15,31 @@ export function isMarkedForDeletion(entity: Entity): boolean {
export function getDeletionTimestamp(entity: Entity): string | undefined {
return entity.metadata.annotations?.[CHOREO_ANNOTATIONS.DELETION_TIMESTAMP];
}
+
+/** Stable identity for a listing row, independent of object reference. */
+export function entityDeletionKey(entity: Entity): string {
+ return `${entity.kind.toLowerCase()}:${
+ entity.metadata.namespace || 'default'
+ }/${entity.metadata.name}`;
+}
+
+/**
+ * Returns a copy of the entity carrying the deletion-timestamp annotation,
+ * so listings can show the "marked for deletion" badge immediately after a
+ * delete — before the catalog re-ingests the deletion from the control plane.
+ */
+export function markEntityForDeletionLocally(entity: Entity): Entity {
+ if (isMarkedForDeletion(entity)) {
+ return entity;
+ }
+ return {
+ ...entity,
+ metadata: {
+ ...entity.metadata,
+ annotations: {
+ ...(entity.metadata.annotations ?? {}),
+ [CHOREO_ANNOTATIONS.DELETION_TIMESTAMP]: new Date().toISOString(),
+ },
+ },
+ };
+}
diff --git a/plugins/openchoreo/src/components/DeleteEntity/utils/index.ts b/plugins/openchoreo/src/components/DeleteEntity/utils/index.ts
index 9514dce25..9e3a633d7 100644
--- a/plugins/openchoreo/src/components/DeleteEntity/utils/index.ts
+++ b/plugins/openchoreo/src/components/DeleteEntity/utils/index.ts
@@ -1 +1,6 @@
-export { isMarkedForDeletion, getDeletionTimestamp } from './deletionUtils';
+export {
+ isMarkedForDeletion,
+ getDeletionTimestamp,
+ entityDeletionKey,
+ markEntityForDeletionLocally,
+} from './deletionUtils';
diff --git a/plugins/openchoreo/src/components/Namespaces/NamespaceProjectsCard/NamespaceProjectsCard.tsx b/plugins/openchoreo/src/components/Namespaces/NamespaceProjectsCard/NamespaceProjectsCard.tsx
index 64359af8f..930332588 100644
--- a/plugins/openchoreo/src/components/Namespaces/NamespaceProjectsCard/NamespaceProjectsCard.tsx
+++ b/plugins/openchoreo/src/components/Namespaces/NamespaceProjectsCard/NamespaceProjectsCard.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from 'react';
import { Link, Table, TableColumn } from '@backstage/core-components';
import { useApp } from '@backstage/core-plugin-api';
import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model';
@@ -5,7 +6,13 @@ import { useEntity, useRelatedEntities } from '@backstage/plugin-catalog-react';
import { Box, Button, Tooltip, Typography } from '@material-ui/core';
import AddIcon from '@material-ui/icons/Add';
import { useNavigate } from 'react-router-dom';
-import { isMarkedForDeletion, DeletionBadge } from '../../DeleteEntity';
+import {
+ isMarkedForDeletion,
+ DeletionBadge,
+ RowDeleteButton,
+ useDeleteEntityDialog,
+ usePendingDeletionOverlay,
+} from '../../DeleteEntity';
import { useScopedProjectCreatePermission } from '@openchoreo/backstage-plugin-react';
import { shouldNavigateOnRowClick } from '../../../utils/shouldNavigateOnRowClick';
import { useNamespaceProjectsCardStyles } from './styles';
@@ -26,6 +33,18 @@ export const NamespaceProjectsCard = () => {
} = useScopedProjectCreatePermission();
const navigate = useNavigate();
+ // Row-level project delete. The related-entities list has no refetch, so
+ // deleted rows are overlaid with the deletion mark until the catalog sync
+ // drops them.
+ const { markDeleted, overlay } = usePendingDeletionOverlay();
+ const { requestDelete, DeleteDialog } = useDeleteEntityDialog({
+ onDeleted: markDeleted,
+ });
+ const displayedSystems = useMemo(
+ () => overlay(systems || []),
+ [systems, overlay],
+ );
+
const columns: TableColumn[] = [
{
title: 'Name',
@@ -65,6 +84,15 @@ export const NamespaceProjectsCard = () => {
),
},
+ {
+ title: '',
+ sorting: false,
+ width: '5%',
+ cellStyle: { textAlign: 'right', paddingRight: 8 },
+ render: (row: Entity) => (
+
+ ),
+ },
];
return (
@@ -72,7 +100,7 @@ export const NamespaceProjectsCard = () => {
{
if (
@@ -134,6 +162,7 @@ export const NamespaceProjectsCard = () => {
),
}}
/>
+
);
};
diff --git a/plugins/openchoreo/src/components/Namespaces/NamespaceResourcesCard/NamespaceResourcesCard.tsx b/plugins/openchoreo/src/components/Namespaces/NamespaceResourcesCard/NamespaceResourcesCard.tsx
index 0dd219627..fd92422b0 100644
--- a/plugins/openchoreo/src/components/Namespaces/NamespaceResourcesCard/NamespaceResourcesCard.tsx
+++ b/plugins/openchoreo/src/components/Namespaces/NamespaceResourcesCard/NamespaceResourcesCard.tsx
@@ -1,9 +1,17 @@
+import { useMemo } from 'react';
import { Link, Table, TableColumn } from '@backstage/core-components';
import { useApp } from '@backstage/core-plugin-api';
import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model';
import { useEntity, useRelatedEntities } from '@backstage/plugin-catalog-react';
import { Box, Typography } from '@material-ui/core';
import { useNavigate } from 'react-router-dom';
+import {
+ isMarkedForDeletion,
+ DeletionBadge,
+ RowDeleteButton,
+ useDeleteEntityDialog,
+ usePendingDeletionOverlay,
+} from '../../DeleteEntity';
import { shouldNavigateOnRowClick } from '../../../utils/shouldNavigateOnRowClick';
import { useNamespaceResourcesCardStyles } from './styles';
@@ -26,8 +34,21 @@ export const NamespaceResourcesCard = () => {
type: RELATION_HAS_PART,
});
- const resources = (entities || []).filter(
- e => e.kind.toLowerCase() !== 'system',
+ // Row-level delete (components and resources; RowDeleteButton hides itself
+ // for non-deletable kinds like `api`). The related-entities list has no
+ // refetch, so deleted rows are overlaid with the deletion mark until the
+ // catalog sync drops them.
+ const { markDeleted, overlay } = usePendingDeletionOverlay();
+ const { requestDelete, DeleteDialog } = useDeleteEntityDialog({
+ onDeleted: markDeleted,
+ });
+
+ const resources = useMemo(
+ () =>
+ overlay(
+ (entities || []).filter(e => e.kind.toLowerCase() !== 'system'),
+ ),
+ [entities, overlay],
);
const columns: TableColumn[] = [
@@ -37,16 +58,26 @@ export const NamespaceResourcesCard = () => {
highlight: true,
render: (row: Entity) => {
const Icon = app.getSystemIcon(`kind:${row.kind.toLowerCase()}`);
+ const name = row.metadata.title || row.metadata.name;
return (
{Icon && }
-
- {row.metadata.title || row.metadata.name}
-
+ {isMarkedForDeletion(row) ? (
+ <>
+
+ {name}
+
+
+ >
+ ) : (
+
+ {name}
+
+ )}
);
},
@@ -67,6 +98,15 @@ export const NamespaceResourcesCard = () => {
),
},
+ {
+ title: '',
+ sorting: false,
+ width: '5%',
+ cellStyle: { textAlign: 'right', paddingRight: 8 },
+ render: (row: Entity) => (
+
+ ),
+ },
];
return (
@@ -77,7 +117,12 @@ export const NamespaceResourcesCard = () => {
data={resources}
isLoading={loading}
onRowClick={(event, rowData) => {
- if (!rowData || !shouldNavigateOnRowClick(event)) return;
+ if (
+ !rowData ||
+ !shouldNavigateOnRowClick(event) ||
+ isMarkedForDeletion(rowData)
+ )
+ return;
const ns = rowData.metadata.namespace || 'default';
navigate(
`/catalog/${ns}/${rowData.kind.toLowerCase()}/${
@@ -103,6 +148,7 @@ export const NamespaceResourcesCard = () => {
}}
style={{ minWidth: 0, width: '100%', height: 'calc(100% - 10px)' }}
/>
+
);
};
diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx
index 1b562b0d1..b0da43bf1 100644
--- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx
+++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx
@@ -65,9 +65,18 @@ jest.mock('@backstage/core-components', () => ({
TableColumn: {},
}));
+const mockRequestDelete = jest.fn();
jest.mock('../../DeleteEntity', () => ({
isMarkedForDeletion: () => false,
DeletionBadge: () => null,
+ RowDeleteButton: () => null,
+ entityDeletionKey: (entity: any) =>
+ `${entity.kind}:${entity.metadata.namespace}/${entity.metadata.name}`,
+ markEntityForDeletionLocally: (entity: any) => entity,
+ useDeleteEntityDialog: () => ({
+ requestDelete: mockRequestDelete,
+ DeleteDialog: () => null,
+ }),
}));
jest.mock('../../../utils/errorUtils', () => ({
isForbiddenError: () => false,
diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx
index 720266216..34fb45d2b 100644
--- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx
+++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx
@@ -1,4 +1,5 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Entity } from '@backstage/catalog-model';
import { Table } from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useNavigate } from 'react-router-dom';
@@ -24,7 +25,12 @@ import {
type ProjectContentKind,
type ProjectContentsOrderBy,
} from '../hooks';
-import { isMarkedForDeletion } from '../../DeleteEntity';
+import {
+ entityDeletionKey,
+ isMarkedForDeletion,
+ markEntityForDeletionLocally,
+ useDeleteEntityDialog,
+} from '../../DeleteEntity';
import { shouldNavigateOnRowClick } from '../../../utils/shouldNavigateOnRowClick';
import {
MultiSelectFilter,
@@ -39,6 +45,21 @@ import { useProjectContentsCardStyles } from './styles';
const PAGE_SIZE = 5;
const KIND_ORDER: ProjectContentKind[] = ['component', 'resource'];
+/**
+ * Mark a row as deleted in the UI immediately, before the catalog re-ingests
+ * the deletion. The listing reads "marked for deletion" from the catalog
+ * entity's annotation, but a delete only updates the control plane — the
+ * catalog lags by a sync/event. We inject the annotation locally so the badge
+ * shows right away (the entity page gets the same effect by querying the OC
+ * API directly).
+ */
+const withOptimisticDeletion = (
+ item: ProjectContentItem,
+): ProjectContentItem => {
+ const marked = markEntityForDeletionLocally(item.entity);
+ return marked === item.entity ? item : { ...item, entity: marked };
+};
+
function useDebouncedValue(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
@@ -65,6 +86,13 @@ export const ProjectContentsCard = () => {
}>({ by: 'createdAt', dir: 'desc' });
const [cursor, setCursor] = useState();
const [pageIndex, setPageIndex] = useState(0);
+ // Bumped after a row is marked for deletion to re-fetch the page.
+ const [refreshToken, setRefreshToken] = useState(0);
+ // Rows deleted from the listing this session — marked optimistically until
+ // the catalog catches up (keyed by entity identity, not object reference).
+ const [pendingDeletions, setPendingDeletions] = useState>(
+ () => new Set(),
+ );
const resetToFirstPage = () => {
setCursor(undefined);
@@ -87,6 +115,23 @@ export const ProjectContentsCard = () => {
orderDir: sort.dir,
cursor,
limit: PAGE_SIZE,
+ refreshToken,
+ });
+
+ // Row-level delete (components and resources). On success we (1)
+ // optimistically mark the row so the "marked for deletion" badge shows
+ // immediately, and (2) re-fetch so the row eventually drops off once the
+ // catalog removes the entity.
+ const handleDeleted = useCallback((deletedEntity: Entity) => {
+ setPendingDeletions(prev => {
+ const next = new Set(prev);
+ next.add(entityDeletionKey(deletedEntity));
+ return next;
+ });
+ setRefreshToken(token => token + 1);
+ }, []);
+ const { requestDelete, DeleteDialog } = useDeleteEntityDialog({
+ onDeleted: handleDeleted,
});
const { environments, loading: envsLoading } = useEnvironments(entity);
@@ -156,8 +201,28 @@ export const ProjectContentsCard = () => {
canViewBindings,
pipelineError,
environmentsLoading,
+ onDeleteItem: item => requestDelete(item.entity),
}),
- [pipelineEnvironments, canViewBindings, pipelineError, environmentsLoading],
+ [
+ pipelineEnvironments,
+ canViewBindings,
+ pipelineError,
+ environmentsLoading,
+ requestDelete,
+ ],
+ );
+
+ // Apply optimistic deletion marks on top of the fetched page.
+ const displayItems = useMemo(
+ () =>
+ pendingDeletions.size === 0
+ ? page.items
+ : page.items.map(item =>
+ pendingDeletions.has(entityDeletionKey(item.entity))
+ ? withOptimisticDeletion(item)
+ : item,
+ ),
+ [page.items, pendingDeletions],
);
// --- Handlers ----------------------------------------------------------
@@ -254,7 +319,7 @@ export const ProjectContentsCard = () => {
columns={columns}
- data={page.items}
+ data={displayItems}
isLoading={tableLoading}
onOrderChange={handleOrderChange}
onRowClick={(event, rowData) => {
@@ -324,6 +389,8 @@ export const ProjectContentsCard = () => {
)}
>
)}
+
+
);
};
diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx
new file mode 100644
index 000000000..6aee0c7ea
--- /dev/null
+++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx
@@ -0,0 +1,78 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common';
+import { RowActionsCell } from './RowActionsCell';
+import { type ProjectContentItem } from '../hooks';
+
+function makeItem(
+ kind: 'component' | 'resource',
+ name: string,
+ options?: { markedForDeletion?: boolean },
+): ProjectContentItem {
+ return {
+ entity: {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: kind === 'component' ? 'Component' : 'Resource',
+ metadata: {
+ name,
+ namespace: 'default',
+ annotations: options?.markedForDeletion
+ ? { [CHOREO_ANNOTATIONS.DELETION_TIMESTAMP]: '2026-01-01T00:00:00Z' }
+ : {},
+ },
+ spec: {},
+ },
+ kind,
+ name,
+ displayName: name,
+ type: 'deployment/service',
+ description: '',
+ deploymentStatus: {},
+ deploymentLoaded: true,
+ };
+}
+
+describe('RowActionsCell', () => {
+ it('renders a delete button for a component row', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole('button', { name: /delete svc/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders a delete button for a resource row', () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole('button', { name: /delete db/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('calls onDelete with the item when the delete button is clicked', async () => {
+ const user = userEvent.setup();
+ const onDelete = jest.fn();
+ const item = makeItem('component', 'svc');
+
+ render();
+
+ await user.click(screen.getByRole('button', { name: /delete svc/i }));
+
+ await waitFor(() => expect(onDelete).toHaveBeenCalledWith(item));
+ });
+
+ it('renders nothing for a row already marked for deletion', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx
new file mode 100644
index 000000000..8ea6ff9d9
--- /dev/null
+++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx
@@ -0,0 +1,17 @@
+import { RowDeleteButton } from '../../DeleteEntity';
+import { type ProjectContentItem } from '../hooks';
+
+interface RowActionsCellProps {
+ item: ProjectContentItem;
+ /** Open the delete-confirmation dialog for this row's entity. */
+ onDelete: (item: ProjectContentItem) => void;
+}
+
+/**
+ * Per-row delete control for the Project Contents table. Both component and
+ * resource rows are deletable; a row already marked for deletion shows
+ * nothing (RowDeleteButton owns that gating and the click-bubbling stop).
+ */
+export const RowActionsCell = ({ item, onDelete }: RowActionsCellProps) => (
+ onDelete(item)} />
+);
diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/columns.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/columns.tsx
index 7680af67e..07f6655ca 100644
--- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/columns.tsx
+++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/columns.tsx
@@ -9,6 +9,7 @@ import { isForbiddenError } from '../../../utils/errorUtils';
import { type Environment, type ProjectContentItem } from '../hooks';
import { KindCell } from './KindCell';
import { DeploymentStatusCell } from './DeploymentStatusCell';
+import { RowActionsCell } from './RowActionsCell';
import { useProjectContentsCardStyles } from './styles';
const NameCell = ({ item }: { item: ProjectContentItem }) => {
@@ -123,6 +124,8 @@ interface BuildColumnsArgs {
pipelineError: unknown;
/** Pipeline environments / permission still loading — skeleton the column. */
environmentsLoading: boolean;
+ /** Open the delete-confirmation dialog for a row. */
+ onDeleteItem: (item: ProjectContentItem) => void;
}
export function buildProjectContentColumns({
@@ -130,12 +133,13 @@ export function buildProjectContentColumns({
canViewBindings,
pipelineError,
environmentsLoading,
+ onDeleteItem,
}: BuildColumnsArgs): TableColumn[] {
return [
{
title: 'Name',
field: 'displayName',
- width: '16%',
+ width: '15%',
highlight: true,
// Ordering is server-side (see onOrderChange); the no-op keeps the
// clickable header + arrow without reordering the current page locally.
@@ -174,7 +178,7 @@ export function buildProjectContentColumns({
},
{
title: 'Deployment',
- width: '30%',
+ width: '26%',
sorting: false,
render: item => (
0,
render: item => ,
},
+ {
+ title: '',
+ width: '5%',
+ sorting: false,
+ cellStyle: { textAlign: 'right', paddingRight: 8 },
+ headerStyle: { textAlign: 'right' },
+ render: item => (
+
+ ),
+ },
];
}
diff --git a/plugins/openchoreo/src/components/Projects/hooks/useProjectContentsPage.ts b/plugins/openchoreo/src/components/Projects/hooks/useProjectContentsPage.ts
index 22f5d869b..3901745a1 100644
Binary files a/plugins/openchoreo/src/components/Projects/hooks/useProjectContentsPage.ts and b/plugins/openchoreo/src/components/Projects/hooks/useProjectContentsPage.ts differ
diff --git a/plugins/openchoreo/src/index.ts b/plugins/openchoreo/src/index.ts
index 4f275f220..d0d8fffc3 100644
--- a/plugins/openchoreo/src/index.ts
+++ b/plugins/openchoreo/src/index.ts
@@ -34,12 +34,19 @@ export {
} from './components/Namespaces';
export {
useDeleteEntityMenuItems,
+ useDeleteEntityDialog,
+ usePendingDeletionOverlay,
useEntityExistsCheck,
+ getEntityDisplayType,
+ isDeletableEntityKind,
DeletionBadge,
DeletionWarning,
+ RowDeleteButton,
isMarkedForDeletion,
getDeletionTimestamp,
type DeletePermissionInfo,
+ type UseDeleteEntityDialogOptions,
+ type UseDeleteEntityDialogResult,
} from './components/DeleteEntity';
export { useAnnotationEditorMenuItems } from './components/AnnotationEditor';
export {