From c0e7d5dd9014debf41c8884d2cc6ab5f825fbf9e Mon Sep 17 00:00:00 2001 From: isuruRuhu Date: Thu, 25 Jun 2026 23:39:04 +0530 Subject: [PATCH 1/3] feat(openchoreo): add component delete to the project listing Signed-off-by: isuruRuhu --- .../component-delete-from-project-listing.md | 10 + .../components/DeleteEntityDialog.tsx | 103 +++++++++ .../DeleteEntity/components/index.ts | 2 + .../components/DeleteEntity/hooks/index.ts | 5 + .../hooks/useDeleteComponentDialog.test.tsx | 197 ++++++++++++++++++ .../hooks/useDeleteComponentDialog.tsx | 105 ++++++++++ .../hooks/useDeleteEntityMenuItems.tsx | 106 +++------- .../src/components/DeleteEntity/index.ts | 10 +- .../ProjectContentsCard.test.tsx | 5 + .../ProjectContentsCard.tsx | 89 +++++++- .../RowActionsCell.test.tsx | 80 +++++++ .../ProjectContentsCard/RowActionsCell.tsx | 42 ++++ .../Projects/ProjectContentsCard/columns.tsx | 18 +- .../Projects/hooks/useProjectContentsPage.ts | Bin 9343 -> 9573 bytes 14 files changed, 689 insertions(+), 83 deletions(-) create mode 100644 .changeset/component-delete-from-project-listing.md create mode 100644 plugins/openchoreo/src/components/DeleteEntity/components/DeleteEntityDialog.tsx create mode 100644 plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.test.tsx create mode 100644 plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.tsx create mode 100644 plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx create mode 100644 plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx diff --git a/.changeset/component-delete-from-project-listing.md b/.changeset/component-delete-from-project-listing.md new file mode 100644 index 000000000..29017f2d9 --- /dev/null +++ b/.changeset/component-delete-from-project-listing.md @@ -0,0 +1,10 @@ +--- +'@openchoreo/backstage-plugin': minor +--- + +Add a per-row "Delete" action to the Project Contents table so a component +can be deleted directly from the project listing without opening the +component page first. The action surfaces in a row "more actions" kebab for +component rows (hidden for resources and for rows already marked for +deletion), reuses the shared delete-confirmation dialog, and refreshes the +listing once the component is marked for deletion. diff --git a/plugins/openchoreo/src/components/DeleteEntity/components/DeleteEntityDialog.tsx b/plugins/openchoreo/src/components/DeleteEntity/components/DeleteEntityDialog.tsx new file mode 100644 index 000000000..ed81ead4a --- /dev/null +++ b/plugins/openchoreo/src/components/DeleteEntity/components/DeleteEntityDialog.tsx @@ -0,0 +1,103 @@ +import { type ReactNode } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + CircularProgress, +} from '@material-ui/core'; +import { useStyles } from '../styles'; + +export interface DeleteEntityDialogProps { + /** Whether the dialog is open. */ + open: boolean; + /** Human-friendly type label, e.g. "Component", "Project". */ + entityDisplayType: string; + /** Name of the entity being deleted. */ + entityName: string; + /** True while the delete request is in flight. */ + deleting: boolean; + /** Error message to surface inside the dialog, if any. */ + error: string | null; + /** Optional cascade note (e.g. "all components will also be deleted"). */ + cascadeNote?: ReactNode; + /** Called when the dialog is dismissed (Cancel / backdrop). */ + onClose: () => void; + /** Called when the user confirms the deletion. */ + onConfirm: () => void; +} + +/** + * Presentational confirmation dialog shared by every "delete" entry point + * (entity-page context menu and the project-listing row action), so the + * wording, styling and busy/error states stay identical across the portal. + * + * It owns no delete logic — the caller wires `onConfirm`/`onClose` and the + * `deleting`/`error` state. + */ +export function DeleteEntityDialog({ + open, + entityDisplayType, + entityName, + deleting, + error, + cascadeNote, + onClose, + onConfirm, +}: DeleteEntityDialogProps) { + const classes = useStyles(); + const lowerType = entityDisplayType.toLowerCase(); + + return ( + + + Delete {entityDisplayType} + + + + + Are you sure you want to delete the {lowerType}{' '} + {entityName}? + + + + This action cannot be undone. The {lowerType} and all its associated + resources will be permanently deleted. + + + {cascadeNote} + + {error && ( + + Error: {error} + + )} + + + + + + + + ); +} diff --git a/plugins/openchoreo/src/components/DeleteEntity/components/index.ts b/plugins/openchoreo/src/components/DeleteEntity/components/index.ts index ba942cfcb..f36f0894e 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/components/index.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/components/index.ts @@ -1,2 +1,4 @@ export { DeletionBadge } from './DeletionBadge'; export { DeletionWarning } from './DeletionWarning'; +export { DeleteEntityDialog } from './DeleteEntityDialog'; +export type { DeleteEntityDialogProps } from './DeleteEntityDialog'; diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts b/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts index 5f31ca0a9..c2a3a6448 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts @@ -2,4 +2,9 @@ export { useDeleteEntityMenuItems, type DeletePermissionInfo, } from './useDeleteEntityMenuItems'; +export { + useDeleteComponentDialog, + type UseDeleteComponentDialogOptions, + type UseDeleteComponentDialogResult, +} from './useDeleteComponentDialog'; export { useEntityExistsCheck } from './useEntityExistsCheck'; diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.test.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.test.tsx new file mode 100644 index 000000000..fa5a5fae0 --- /dev/null +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.test.tsx @@ -0,0 +1,197 @@ +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 { useDeleteComponentDialog } from './useDeleteComponentDialog'; +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 makeComponent(name: string): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name, + namespace: 'default', + annotations: { 'openchoreo.io/namespace': 'test-ns' }, + }, + spec: {}, + }; +} + +/** Renders the hook: a button per entity opens the shared dialog. */ +function TestHarness({ + entities, + onDeleted, +}: { + entities: Entity[]; + onDeleted?: () => void; +}) { + const { requestDelete, DeleteDialog } = useDeleteComponentDialog({ + onDeleted, + }); + + return ( +
+ {entities.map(entity => ( + + ))} + +
+ ); +} + +function renderHarness(entities: Entity[], onDeleted?: () => void) { + return render( + + + + + , + ); +} + +// ---- Tests ---- + +describe('useDeleteComponentDialog', () => { + 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('targets the component 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/useDeleteComponentDialog.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.tsx new file mode 100644 index 000000000..c4060b2d3 --- /dev/null +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.tsx @@ -0,0 +1,105 @@ +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'; + +export interface UseDeleteComponentDialogOptions { + /** + * Called after a component 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 UseDeleteComponentDialogResult { + /** Open the confirmation dialog for the given component entity. */ + requestDelete: (entity: Entity) => void; + /** Render once in the surrounding component; controlled internally. */ + DeleteDialog: React.FC; +} + +/** + * Delete a component from a listing (e.g. the project's Project Contents table) + * 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 a refetch) instead of navigating away. Component-only by design; + * the project listing has no client-side delete for Resource entities. + */ +export function useDeleteComponentDialog( + options?: UseDeleteComponentDialogOptions, +): UseDeleteComponentDialogResult { + 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; + setDeleting(true); + setError(null); + + try { + await openChoreoClient.deleteComponent(target); + + alertApi.post({ + message: `Component "${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 component. 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..6e4629192 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx @@ -1,13 +1,5 @@ import { useState, useCallback, useMemo } from 'react'; -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - Typography, - CircularProgress, -} from '@material-ui/core'; +import { Typography } 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'; @@ -18,8 +10,8 @@ import { CLUSTER_SCOPED_RESOURCE_KINDS, } from '../../../api/OpenChoreoClientApi'; import { isForbiddenError, getErrorMessage } from '../../../utils/errorUtils'; -import { useStyles } from '../styles'; import { isMarkedForDeletion } from '../utils'; +import { DeleteEntityDialog } from '../components'; import { isSupportedKind, mapKindToApiKind, @@ -91,7 +83,6 @@ 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; @@ -216,82 +207,45 @@ export function useDeleteEntityMenuItems( ]; }, [canDelete, entityDisplayType, handleOpenDialog, deletePermission]); + const cascadeNote = useMemo(() => { + if (isProject) { + return ( + + Note: All components within this project will also be deleted. + + ); + } + if (isDomain) { + return ( + + Note: All projects and components within this namespace will also be + deleted. + + ); + } + return undefined; + }, [isProject, isDomain]); + const DeleteConfirmationDialog: React.FC = useCallback( () => ( - - - Delete {entityDisplayType} - - - - - Are you sure you want to delete the{' '} - {entityDisplayType.toLowerCase()}{' '} - {entityName}? - - - - This action cannot be undone. The {entityDisplayType.toLowerCase()}{' '} - and all its associated resources will be permanently deleted. - - - {isProject && ( - - Note: All components within this project will also be deleted. - - )} - - {isDomain && ( - - Note: All projects and components within this namespace will also - be deleted. - - )} - - {error && ( - - Error: {error} - - )} - - - - - - - + onConfirm={handleConfirmDelete} + /> ), [ dialogOpen, handleCloseDialog, handleConfirmDelete, - classes, entityDisplayType, entityName, - isProject, - isDomain, + cascadeNote, error, deleting, ], diff --git a/plugins/openchoreo/src/components/DeleteEntity/index.ts b/plugins/openchoreo/src/components/DeleteEntity/index.ts index 809747973..36f1eeba8 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/index.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/index.ts @@ -1,12 +1,20 @@ // Hooks export { useDeleteEntityMenuItems, + useDeleteComponentDialog, useEntityExistsCheck, type DeletePermissionInfo, + type UseDeleteComponentDialogOptions, + type UseDeleteComponentDialogResult, } from './hooks'; // Components -export { DeletionBadge, DeletionWarning } from './components'; +export { + DeletionBadge, + DeletionWarning, + DeleteEntityDialog, +} from './components'; +export type { DeleteEntityDialogProps } from './components'; // Utils export { isMarkedForDeletion, getDeletionTimestamp } from './utils'; diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx index 1b562b0d1..b9ba9666d 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx @@ -65,9 +65,14 @@ jest.mock('@backstage/core-components', () => ({ TableColumn: {}, })); +const mockRequestDelete = jest.fn(); jest.mock('../../DeleteEntity', () => ({ isMarkedForDeletion: () => false, DeletionBadge: () => null, + useDeleteComponentDialog: () => ({ + 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..f5dc22c75 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx @@ -1,7 +1,9 @@ -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'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { Box, IconButton, @@ -24,7 +26,10 @@ import { type ProjectContentKind, type ProjectContentsOrderBy, } from '../hooks'; -import { isMarkedForDeletion } from '../../DeleteEntity'; +import { + isMarkedForDeletion, + useDeleteComponentDialog, +} from '../../DeleteEntity'; import { shouldNavigateOnRowClick } from '../../../utils/shouldNavigateOnRowClick'; import { MultiSelectFilter, @@ -39,6 +44,37 @@ import { useProjectContentsCardStyles } from './styles'; const PAGE_SIZE = 5; const KIND_ORDER: ProjectContentKind[] = ['component', 'resource']; +/** Stable identity for a content row, independent of object reference. */ +const entityKey = (entity: Entity): string => + `${entity.kind.toLowerCase()}:${entity.metadata.namespace || 'default'}/${ + entity.metadata.name + }`; + +/** + * 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 => + isMarkedForDeletion(item.entity) + ? item + : { + ...item, + entity: { + ...item.entity, + metadata: { + ...item.entity.metadata, + annotations: { + ...(item.entity.metadata.annotations ?? {}), + [CHOREO_ANNOTATIONS.DELETION_TIMESTAMP]: new Date().toISOString(), + }, + }, + }, + }; + function useDebouncedValue(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { @@ -65,6 +101,13 @@ export const ProjectContentsCard = () => { }>({ by: 'createdAt', dir: 'desc' }); const [cursor, setCursor] = useState(); const [pageIndex, setPageIndex] = useState(0); + // Bumped after a component 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 +130,22 @@ export const ProjectContentsCard = () => { orderDir: sort.dir, cursor, limit: PAGE_SIZE, + refreshToken, + }); + + // Row-level component delete. 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 component. + const handleDeleted = useCallback((deletedEntity: Entity) => { + setPendingDeletions(prev => { + const next = new Set(prev); + next.add(entityKey(deletedEntity)); + return next; + }); + setRefreshToken(token => token + 1); + }, []); + const { requestDelete, DeleteDialog } = useDeleteComponentDialog({ + onDeleted: handleDeleted, }); const { environments, loading: envsLoading } = useEnvironments(entity); @@ -156,8 +215,28 @@ export const ProjectContentsCard = () => { canViewBindings, pipelineError, environmentsLoading, + onDeleteComponent: 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(entityKey(item.entity)) + ? withOptimisticDeletion(item) + : item, + ), + [page.items, pendingDeletions], ); // --- Handlers ---------------------------------------------------------- @@ -254,7 +333,7 @@ export const ProjectContentsCard = () => { columns={columns} - data={page.items} + data={displayItems} isLoading={tableLoading} onOrderChange={handleOrderChange} onRowClick={(event, rowData) => { @@ -324,6 +403,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..c9dfc45a6 --- /dev/null +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx @@ -0,0 +1,80 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RowActionsCell } from './RowActionsCell'; +import { type ProjectContentItem } from '../hooks'; + +// isMarkedForDeletion is annotation-driven; mock it so each test controls it. +const mockIsMarkedForDeletion = jest.fn(); +jest.mock('../../DeleteEntity', () => ({ + isMarkedForDeletion: (...args: any[]) => mockIsMarkedForDeletion(...args), +})); + +function makeItem( + kind: 'component' | 'resource', + name: string, +): ProjectContentItem { + return { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: kind === 'component' ? 'Component' : 'Resource', + metadata: { name, namespace: 'default' }, + spec: {}, + }, + kind, + name, + displayName: name, + type: 'deployment/service', + description: '', + deploymentStatus: {}, + deploymentLoaded: true, + }; +} + +describe('RowActionsCell', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsMarkedForDeletion.mockReturnValue(false); + }); + + it('renders a delete button for a component row', () => { + render( + , + ); + expect( + screen.getByRole('button', { name: /delete svc/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 resource row', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing for a component already marked for deletion', () => { + mockIsMarkedForDeletion.mockReturnValue(true); + 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..6e8ebe11a --- /dev/null +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx @@ -0,0 +1,42 @@ +import { type MouseEvent } from 'react'; +import { IconButton, Tooltip } from '@material-ui/core'; +import DeleteOutlineIcon from '@material-ui/icons/DeleteOutline'; +import { isMarkedForDeletion } from '../../DeleteEntity'; +import { type ProjectContentItem } from '../hooks'; + +interface RowActionsCellProps { + item: ProjectContentItem; + /** Open the delete-confirmation dialog for this component. */ + onDelete: (item: ProjectContentItem) => void; +} + +/** + * Per-row delete control for the Project Contents table. + * + * Only components are deletable from the listing — Resource rows have no + * client-side delete, and a row already marked for deletion shows nothing. + * The click is stopped from bubbling so the row's navigate-on-click doesn't + * fire. + */ +export const RowActionsCell = ({ item, onDelete }: RowActionsCellProps) => { + if (item.kind !== 'component' || isMarkedForDeletion(item.entity)) { + return null; + } + + const handleDelete = (event: MouseEvent) => { + event.stopPropagation(); + onDelete(item); + }; + + return ( + + + + + + ); +}; diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/columns.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/columns.tsx index 7680af67e..84bb464b6 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 component row. */ + onDeleteComponent: (item: ProjectContentItem) => void; } export function buildProjectContentColumns({ @@ -130,12 +133,13 @@ export function buildProjectContentColumns({ canViewBindings, pipelineError, environmentsLoading, + onDeleteComponent, }: 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 22f5d869b21f44cfd7513ad151a887c95c060f9b..3901745a1d06d7afdcdb6fed61577b25498dfcb6 100644 GIT binary patch delta 235 zcmY+7F$%&!6hvE55y1;gkwOEmtyY4KVq;@v_gR0kA<6E#*(lg}2rnY!D4xM{XrdO{ z49w#(^ISdFH_^HPlQ3)p2(htrC>ck}>hy{{9FJY8i@Go(_EO=bSR?U}ige6{z@p4; z1H7s2$Z??>`!gs@=U97KiW!N9%}6qaEMftQJUh?~(&X0YMoS9h?Yn90A;$gG9~?Og2_E0sw2(2zUSh From 29691c70fd2f62f0898a10cbd5612a5f53cd53b5 Mon Sep 17 00:00:00 2001 From: isuruRuhu Date: Fri, 26 Jun 2026 09:14:42 +0530 Subject: [PATCH 2/3] fix(catalog): re-stitch parent System when a component is deleted Signed-off-by: isuruRuhu --- .../refresh-system-on-component-delete.md | 10 ++ .../src/provider/EventDeltaApplier.test.ts | 100 ++++++++++++++++++ .../src/provider/EventDeltaApplier.ts | 81 +++++++++++++- 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 .changeset/refresh-system-on-component-delete.md diff --git a/.changeset/refresh-system-on-component-delete.md b/.changeset/refresh-system-on-component-delete.md new file mode 100644 index 000000000..8b3ec9658 --- /dev/null +++ b/.changeset/refresh-system-on-component-delete.md @@ -0,0 +1,10 @@ +--- +'@openchoreo/backstage-plugin-catalog-backend-module': patch +--- + +Refresh the parent System when a component is removed so its now-dangling +`hasPart` relation is dropped. Without this, deleting a component left the +project page showing "Some related entities could not be found in the catalog" +until the catalog was rebuilt, because the System's relations are only +recomputed when it is re-processed and the periodic full sync re-emits it +unchanged. diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts index 641890a70..ce380d0e2 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.test.ts @@ -841,3 +841,103 @@ describe('EventDeltaApplier.handleEvent', () => { ); }); }); + +// --------------------------------------------------------------------------- +// Removing a Component leaves the parent System's reverse `hasPart` relation +// dangling until the System is re-processed. With a catalog read-side wired, +// the applier resolves the owning System and forces a refresh so the stale +// relation (and the "related entities could not be found" warning) clears. +// --------------------------------------------------------------------------- +describe('EventDeltaApplier component removal refreshes the parent System', () => { + let applyMutation: jest.Mock; + let connection: EntityProviderConnection; + let getEntityByRef: jest.Mock; + let refreshEntity: jest.Mock; + + function newApplierWithCatalog(component: any) { + getEntityByRef = jest.fn().mockResolvedValue(component); + refreshEntity = jest.fn().mockResolvedValue(undefined); + return new EventDeltaApplier({ + logger: mkLogger(), + baseUrl: 'http://test:8080', + defaultOwner: 'group:default/test-owner', + translatorContext: { + providerName: 'OpenChoreoEntityProvider', + defaultOwner: 'group:default/test-owner', + componentTypeUtils: ComponentTypeUtils.fromConfig( + new ConfigReader({ + openchoreo: { componentTypes: { mappings: [] } }, + }), + ), + }, + getConnection: () => connection, + ctdConverter: new CtdToTemplateConverter(mkLogger()), + rtdConverter: new RtdToTemplateConverter(mkLogger()), + ptdConverter: new PtdToTemplateConverter(mkLogger()), + catalogService: { getEntityByRef, refreshEntity } as any, + auth: { + getOwnServiceCredentials: jest.fn().mockResolvedValue({}), + } as any, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + mockGET.mockResolvedValue(notFound()); + applyMutation = jest.fn(); + connection = { applyMutation, refresh: jest.fn() } as any; + }); + + it('refreshes the System from the component partOf relation', async () => { + const applier = newApplierWithCatalog({ + kind: 'Component', + metadata: { name: 'order', namespace: 'test-ns' }, + spec: { system: 'shop' }, + relations: [ + { type: 'partOf', targetRef: 'system:test-ns/shop' }, + { type: 'ownedBy', targetRef: 'group:default/team' }, + ], + }); + + await applier.handleEvent('Component', 'order', 'test-ns', 'deleted'); + + // Parent resolved before removal, component removed, parent refreshed. + expect(getEntityByRef).toHaveBeenCalledWith( + 'component:test-ns/order', + expect.anything(), + ); + expect( + applyMutation.mock.calls[0][0].removed.map((r: any) => r.entityRef), + ).toEqual(['component:test-ns/order']); + expect(refreshEntity).toHaveBeenCalledWith( + 'system:test-ns/shop', + expect.anything(), + ); + }); + + it('falls back to spec.system when there is no partOf relation', async () => { + const applier = newApplierWithCatalog({ + kind: 'Component', + metadata: { name: 'order', namespace: 'test-ns' }, + spec: { system: 'shop' }, + }); + + await applier.handleEvent('Component', 'order', 'test-ns', 'deleted'); + + expect(refreshEntity).toHaveBeenCalledWith( + 'system:test-ns/shop', + expect.anything(), + ); + }); + + it('removes the component without refreshing when the owner cannot be resolved', async () => { + const applier = newApplierWithCatalog(undefined); + + await applier.handleEvent('Component', 'order', 'test-ns', 'deleted'); + + expect( + applyMutation.mock.calls[0][0].removed.map((r: any) => r.entityRef), + ).toEqual(['component:test-ns/order']); + expect(refreshEntity).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts index 52da042b7..679e04805 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/EventDeltaApplier.ts @@ -1,4 +1,8 @@ -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { + Entity, + stringifyEntityRef, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { AuthService, LoggerService } from '@backstage/backend-plugin-api'; import { CatalogService, @@ -644,6 +648,69 @@ export class EventDeltaApplier { ]); } + /** + * Resolve the Backstage System (OC Project) ref a component belongs to, by + * reading the still-present catalog Component entity. Prefers the `partOf` + * relation (the exact ref Backstage assigned); falls back to `spec.system`. + * Returns undefined when the catalog read-side isn't wired or the owner + * can't be resolved — the caller then just skips the parent refresh. + */ + private async findParentSystemRef( + ns: string, + name: string, + ): Promise { + if (!this.catalogService || !this.auth) { + return undefined; + } + try { + const credentials = await this.auth.getOwnServiceCredentials(); + const entity = await this.catalogService.getEntityByRef( + this.buildEntityRef('component', ns, name), + { credentials }, + ); + if (!entity) { + return undefined; + } + const partOf = entity.relations?.find( + rel => + rel.type === RELATION_PART_OF && rel.targetRef.startsWith('system:'), + ); + if (partOf) { + return partOf.targetRef; + } + const system = (entity.spec as { system?: string } | undefined)?.system; + return system + ? this.buildEntityRef( + 'system', + entity.metadata.namespace ?? 'default', + system, + ) + : undefined; + } catch (err) { + this.logger.warn( + `Could not resolve parent System for component ${ns}/${name}: ${err}`, + ); + return undefined; + } + } + + /** + * Force a re-process (and thus re-stitch) of a single entity. Best-effort: + * logs and continues on failure so it never blocks the mutation that + * triggered it. + */ + private async refreshEntityRef(entityRef: string): Promise { + if (!this.catalogService || !this.auth) { + return; + } + try { + const credentials = await this.auth.getOwnServiceCredentials(); + await this.catalogService.refreshEntity(entityRef, { credentials }); + } catch (err) { + this.logger.warn(`Failed to refresh ${entityRef}: ${err}`); + } + } + private async refreshComponent( ns: string, name: string, @@ -657,7 +724,19 @@ export class EventDeltaApplier { const client = await this.createApiClient(); const component = await this.fetchComponent(client, ns, name); if (!component) { + // Resolve the owning System *before* removing the component — once the + // component entity is gone we can no longer read its `partOf` relation. + const parentSystemRef = await this.findParentSystemRef(ns, name); await this.removeEntityRefs([this.buildEntityRef('component', ns, name)]); + // Removing the component leaves the System's reverse `hasPart` relation + // dangling: the System's denormalized relations are only recomputed when + // it is itself re-processed, and the periodic full sync re-emits it + // unchanged (so it isn't). Until then the project page shows "Some + // related entities could not be found in the catalog". Force a refresh + // of the parent System so it re-stitches and drops the dangling edge. + if (parentSystemRef) { + await this.refreshEntityRef(parentSystemRef); + } return; } From 373f49395231bff049697c73c019b6e4724829be Mon Sep 17 00:00:00 2001 From: isuruRuhu Date: Wed, 8 Jul 2026 12:27:37 +0530 Subject: [PATCH 3/3] feat(openchoreo): add row-level delete to catalog and namespace listings Signed-off-by: isuruRuhu --- .changeset/listing-row-delete-everywhere.md | 13 ++ .../components/catalog/CatalogCardList.tsx | 23 +++- packages/app/src/components/catalog/styles.ts | 17 ++- .../components/RowDeleteButton.test.tsx | 81 +++++++++++ .../components/RowDeleteButton.tsx | 49 +++++++ .../DeleteEntity/components/index.ts | 2 + .../hooks/deleteEntityDispatch.tsx | 128 ++++++++++++++++++ .../components/DeleteEntity/hooks/index.ts | 13 +- ...est.tsx => useDeleteEntityDialog.test.tsx} | 78 ++++++++++- ...ntDialog.tsx => useDeleteEntityDialog.tsx} | 41 ++++-- .../hooks/useDeleteEntityMenuItems.tsx | 108 ++------------- .../hooks/usePendingDeletionOverlay.ts | 52 +++++++ .../src/components/DeleteEntity/index.ts | 22 ++- .../DeleteEntity/utils/deletionUtils.ts | 28 ++++ .../components/DeleteEntity/utils/index.ts | 7 +- .../NamespaceProjectsCard.tsx | 33 ++++- .../NamespaceResourcesCard.tsx | 66 +++++++-- .../ProjectContentsCard.test.tsx | 6 +- .../ProjectContentsCard.tsx | 50 +++---- .../RowActionsCell.test.tsx | 42 +++--- .../ProjectContentsCard/RowActionsCell.tsx | 41 ++---- .../Projects/ProjectContentsCard/columns.tsx | 8 +- plugins/openchoreo/src/index.ts | 7 + 23 files changed, 677 insertions(+), 238 deletions(-) create mode 100644 .changeset/listing-row-delete-everywhere.md create mode 100644 plugins/openchoreo/src/components/DeleteEntity/components/RowDeleteButton.test.tsx create mode 100644 plugins/openchoreo/src/components/DeleteEntity/components/RowDeleteButton.tsx create mode 100644 plugins/openchoreo/src/components/DeleteEntity/hooks/deleteEntityDispatch.tsx rename plugins/openchoreo/src/components/DeleteEntity/hooks/{useDeleteComponentDialog.test.tsx => useDeleteEntityDialog.test.tsx} (70%) rename plugins/openchoreo/src/components/DeleteEntity/hooks/{useDeleteComponentDialog.tsx => useDeleteEntityDialog.tsx} (66%) create mode 100644 plugins/openchoreo/src/components/DeleteEntity/hooks/usePendingDeletionOverlay.ts diff --git a/.changeset/listing-row-delete-everywhere.md b/.changeset/listing-row-delete-everywhere.md new file mode 100644 index 000000000..32decbe90 --- /dev/null +++ b/.changeset/listing-row-delete-everywhere.md @@ -0,0 +1,13 @@ +--- +'@openchoreo/backstage-plugin': minor +--- + +Generalize the listing row delete beyond components. The new +`useDeleteEntityDialog` hook (replacing `useDeleteComponentDialog`) dispatches +deletes for every kind the OpenChoreo API supports — components, projects, +namespaces, resources and the platform resource kinds — sharing one dispatch +with the entity-page context menu. A reusable `RowDeleteButton` + +`usePendingDeletionOverlay` pair wires per-row delete (with an optimistic +"marked for deletion" badge) into the catalog "All ..." pages, the namespace +projects/resources cards, and extends the Project Contents row action to +resource rows. diff --git a/packages/app/src/components/catalog/CatalogCardList.tsx b/packages/app/src/components/catalog/CatalogCardList.tsx index 8b1d560e4..e4869d4f1 100644 --- a/packages/app/src/components/catalog/CatalogCardList.tsx +++ b/packages/app/src/components/catalog/CatalogCardList.tsx @@ -1,4 +1,4 @@ -import { type ReactNode } from 'react'; +import { useMemo, type ReactNode } from 'react'; import { Box, Chip, @@ -21,6 +21,9 @@ import { useNavigate } from 'react-router-dom'; import { DeletionBadge, isMarkedForDeletion, + RowDeleteButton, + useDeleteEntityDialog, + usePendingDeletionOverlay, } from '@openchoreo/backstage-plugin'; import { Entity } from '@backstage/catalog-model'; import { useCardListStyles } from './styles'; @@ -165,6 +168,19 @@ export const CatalogCardList = ({ actionButton }: CatalogCardListProps) => { navigate(url); }; + // Row-level delete for every kind the OC API can delete (projects, + // components, resources, namespaces, environments, platform types, ...). + // The catalog lags a delete by a sync/event, so deleted rows are overlaid + // with the deletion mark until the next refetch drops them. + const { markDeleted, overlay } = usePendingDeletionOverlay(); + const { requestDelete, DeleteDialog } = useDeleteEntityDialog({ + onDeleted: markDeleted, + }); + const displayedEntities = useMemo( + () => overlay(entities), + [entities, overlay], + ); + const kindLabel = filters.kind?.label || filters.kind?.value || 'Entity'; const pluralLabel = kindPluralNames[kindLabel] || `${kindLabel}s`; const titleText = `All ${totalItems === 1 ? kindLabel : pluralLabel}${ @@ -211,7 +227,7 @@ export const CatalogCardList = ({ actionButton }: CatalogCardListProps) => { ))} - {entities.map(entity => { + {displayedEntities.map(entity => { const name = entity.metadata.title || entity.metadata.name || 'Unnamed'; const description = entity.metadata.description || ''; @@ -442,6 +458,7 @@ export const CatalogCardList = ({ actionButton }: CatalogCardListProps) => { )} + ); @@ -468,6 +485,8 @@ export const CatalogCardList = ({ actionButton }: CatalogCardListProps) => { /> )} + + ); }; diff --git a/packages/app/src/components/catalog/styles.ts b/packages/app/src/components/catalog/styles.ts index 69aedf9bb..97934fcee 100644 --- a/packages/app/src/components/catalog/styles.ts +++ b/packages/app/src/components/catalog/styles.ts @@ -126,7 +126,7 @@ export const usePersonalFilterStyles = makeStyles(theme => ({ export const useCardListStyles = makeStyles(theme => { const mobileGrid = { - gridTemplateColumns: '32px 1fr 60px', + gridTemplateColumns: '32px 1fr 100px', }; return { @@ -150,32 +150,32 @@ export const useCardListStyles = makeStyles(theme => { // Grid templates per kind — icon + actions fixed, rest spread evenly // Component: Icon | Name | Description | Namespace | Project | Type | Actions gridTemplateComponent: { - gridTemplateColumns: '40px 1fr 1.5fr 1fr 1fr 1fr 80px', + gridTemplateColumns: '40px 1fr 1.5fr 1fr 1fr 1fr 116px', [theme.breakpoints.down('xs')]: mobileGrid, }, // API: Icon | Name | Description | Namespace | Project | Component | Type | Actions gridTemplateApi: { - gridTemplateColumns: '40px 1.2fr 2fr 0.8fr 0.8fr 0.8fr 0.7fr 80px', + gridTemplateColumns: '40px 1.2fr 2fr 0.8fr 0.8fr 0.8fr 0.7fr 116px', [theme.breakpoints.down('xs')]: mobileGrid, }, // Environment: Icon | Name | Description | Namespace | Type | Actions gridTemplateEnvironment: { - gridTemplateColumns: '40px 1fr 1.5fr 1fr 1fr 80px', + gridTemplateColumns: '40px 1fr 1.5fr 1fr 1fr 116px', [theme.breakpoints.down('xs')]: mobileGrid, }, // Planes: Icon | Name | Description | Namespace | Agent | Actions gridTemplatePlane: { - gridTemplateColumns: '40px 1fr 1.5fr 1fr 1fr 80px', + gridTemplateColumns: '40px 1fr 1.5fr 1fr 1fr 116px', [theme.breakpoints.down('xs')]: mobileGrid, }, // Project/simple: Icon | Name | Description | Namespace | Actions gridTemplateSimple: { - gridTemplateColumns: '40px 1fr 1.5fr 1fr 80px', + gridTemplateColumns: '40px 1fr 1.5fr 1fr 116px', [theme.breakpoints.down('xs')]: mobileGrid, }, // Namespace/domain: Icon | Name | Description | Actions gridTemplateMinimal: { - gridTemplateColumns: '40px 1fr 1.5fr 80px', + gridTemplateColumns: '40px 1fr 1.5fr 116px', [theme.breakpoints.down('xs')]: mobileGrid, }, @@ -239,6 +239,9 @@ export const useCardListStyles = makeStyles(theme => { '& > :first-child': { marginLeft: -8, }, + '& .MuiIconButton-root': { + padding: theme.spacing(0.75), + }, }, columnCell: { fontSize: '0.8rem', diff --git a/plugins/openchoreo/src/components/DeleteEntity/components/RowDeleteButton.test.tsx b/plugins/openchoreo/src/components/DeleteEntity/components/RowDeleteButton.test.tsx new file mode 100644 index 000000000..8a0e7c989 --- /dev/null +++ b/plugins/openchoreo/src/components/DeleteEntity/components/RowDeleteButton.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import type { Entity } from '@backstage/catalog-model'; +import { RowDeleteButton } from './RowDeleteButton'; + +function makeEntity( + kind: string, + name: string, + options?: { markedForDeletion?: boolean }, +): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind, + metadata: { + name, + namespace: 'default', + annotations: options?.markedForDeletion + ? { [CHOREO_ANNOTATIONS.DELETION_TIMESTAMP]: '2026-01-01T00:00:00Z' } + : {}, + }, + spec: {}, + }; +} + +describe('RowDeleteButton', () => { + it.each([ + ['Component', 'my-service'], + ['System', 'my-project'], + ['Domain', 'my-namespace'], + ['Resource', 'my-db'], + ['Environment', 'dev'], + ['ClusterComponentType', 'service'], + ])('renders a delete button for %s entities', (kind, name) => { + render( + , + ); + expect( + screen.getByRole('button', { name: new RegExp(`delete ${name}`, 'i') }), + ).toBeInTheDocument(); + }); + + it.each([['API'], ['User'], ['Group'], ['Template'], ['Location']])( + 'renders nothing for non-deletable kind %s', + kind => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }, + ); + + it('renders nothing for an entity already marked for deletion', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('calls onDelete with the entity and stops click propagation', async () => { + const user = userEvent.setup(); + const onDelete = jest.fn(); + const onRowClick = jest.fn(); + const entity = makeEntity('Component', 'svc'); + + render( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ +
, + ); + + await user.click(screen.getByRole('button', { name: /delete svc/i })); + + expect(onDelete).toHaveBeenCalledWith(entity); + expect(onRowClick).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo/src/components/DeleteEntity/components/RowDeleteButton.tsx b/plugins/openchoreo/src/components/DeleteEntity/components/RowDeleteButton.tsx new file mode 100644 index 000000000..ffcf32236 --- /dev/null +++ b/plugins/openchoreo/src/components/DeleteEntity/components/RowDeleteButton.tsx @@ -0,0 +1,49 @@ +import { type MouseEvent } from 'react'; +import { IconButton, Tooltip } from '@material-ui/core'; +import DeleteOutlineIcon from '@material-ui/icons/DeleteOutline'; +import { Entity } from '@backstage/catalog-model'; +import { isMarkedForDeletion } from '../utils'; +import { + getEntityDisplayType, + isDeletableEntityKind, +} from '../hooks/deleteEntityDispatch'; + +export interface RowDeleteButtonProps { + entity: Entity; + /** Open the delete-confirmation dialog for this entity. */ + onDelete: (entity: Entity) => void; +} + +/** + * Per-row delete control shared by every listing (catalog "All ..." pages, + * Project Contents, namespace projects/resources cards). + * + * Renders nothing for kinds the client can't delete (`api`, `user`, ...) and + * for rows already marked for deletion. The click is stopped from bubbling so + * the row's navigate-on-click doesn't fire. + */ +export const RowDeleteButton = ({ entity, onDelete }: RowDeleteButtonProps) => { + if (!isDeletableEntityKind(entity.kind) || isMarkedForDeletion(entity)) { + return null; + } + + const displayType = getEntityDisplayType(entity.kind); + const displayName = entity.metadata.title || entity.metadata.name; + + const handleDelete = (event: MouseEvent) => { + event.stopPropagation(); + onDelete(entity); + }; + + return ( + + + + + + ); +}; diff --git a/plugins/openchoreo/src/components/DeleteEntity/components/index.ts b/plugins/openchoreo/src/components/DeleteEntity/components/index.ts index f36f0894e..b4e9f831a 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/components/index.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/components/index.ts @@ -2,3 +2,5 @@ export { DeletionBadge } from './DeletionBadge'; export { DeletionWarning } from './DeletionWarning'; export { DeleteEntityDialog } from './DeleteEntityDialog'; export type { DeleteEntityDialogProps } from './DeleteEntityDialog'; +export { RowDeleteButton } from './RowDeleteButton'; +export type { RowDeleteButtonProps } from './RowDeleteButton'; diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/deleteEntityDispatch.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/deleteEntityDispatch.tsx new file mode 100644 index 000000000..64d5fd8e1 --- /dev/null +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/deleteEntityDispatch.tsx @@ -0,0 +1,128 @@ +import { type ReactNode } from 'react'; +import { Typography } from '@material-ui/core'; +import { Entity } from '@backstage/catalog-model'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { + OpenChoreoClientApi, + CLUSTER_SCOPED_RESOURCE_KINDS, +} from '../../../api/OpenChoreoClientApi'; +import { + isSupportedKind, + mapKindToApiKind, +} from '../../ResourceDefinition/utils'; + +/** 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', +}; + +/** Display label for an entity kind, e.g. "system" -> "Project". */ +export function getEntityDisplayType(kind: string): string { + return KIND_DISPLAY_NAMES[kind.toLowerCase()] ?? kind; +} + +/** + * Whether the client can delete entities of this kind at all (the OC API has + * a delete path for it). Kinds like `api`, `user`, `group`, `template` and + * `location` are catalog-only projections and are not deletable. + */ +export function isDeletableEntityKind(kind: string): boolean { + const kindLower = kind.toLowerCase(); + return ( + kindLower === 'component' || + kindLower === 'system' || + kindLower === 'domain' || + isSupportedKind(kindLower) + ); +} + +/** + * Dispatches an entity delete to the right OpenChoreo client call for its + * kind. Single source of truth shared by the entity-page context menu + * ({@link useDeleteEntityMenuItems}) and the listing row action + * ({@link useDeleteEntityDialog}). + */ +export async function performEntityDelete( + client: OpenChoreoClientApi, + entity: Entity, +): Promise { + 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 c2a3a6448..402a9039f 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/index.ts @@ -3,8 +3,13 @@ export { type DeletePermissionInfo, } from './useDeleteEntityMenuItems'; export { - useDeleteComponentDialog, - type UseDeleteComponentDialogOptions, - type UseDeleteComponentDialogResult, -} from './useDeleteComponentDialog'; + 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/useDeleteComponentDialog.test.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.test.tsx similarity index 70% rename from plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.test.tsx rename to plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.test.tsx index fa5a5fae0..35b922f11 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.test.tsx +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.test.tsx @@ -5,7 +5,7 @@ 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 { useDeleteComponentDialog } from './useDeleteComponentDialog'; +import { useDeleteEntityDialog } from './useDeleteEntityDialog'; import type { Entity } from '@backstage/catalog-model'; // ---- Mocks ---- @@ -21,10 +21,10 @@ jest.mock('../../../utils/errorUtils', () => ({ const mockClient = createMockOpenChoreoClient(); const mockAlertApi = { post: jest.fn() }; -function makeComponent(name: string): Entity { +function makeEntity(kind: string, name: string): Entity { return { apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', + kind, metadata: { name, namespace: 'default', @@ -34,6 +34,8 @@ function makeComponent(name: string): Entity { }; } +const makeComponent = (name: string) => makeEntity('Component', name); + /** Renders the hook: a button per entity opens the shared dialog. */ function TestHarness({ entities, @@ -42,7 +44,7 @@ function TestHarness({ entities: Entity[]; onDeleted?: () => void; }) { - const { requestDelete, DeleteDialog } = useDeleteComponentDialog({ + const { requestDelete, DeleteDialog } = useDeleteEntityDialog({ onDeleted, }); @@ -79,7 +81,7 @@ function renderHarness(entities: Entity[], onDeleted?: () => void) { // ---- Tests ---- -describe('useDeleteComponentDialog', () => { +describe('useDeleteEntityDialog', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -130,7 +132,71 @@ describe('useDeleteComponentDialog', () => { ).not.toBeInTheDocument(); }); - it('targets the component whose row requested deletion', async () => { + 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); diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.tsx similarity index 66% rename from plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.tsx rename to plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.tsx index c4060b2d3..597ab58cb 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteComponentDialog.tsx +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityDialog.tsx @@ -4,37 +4,44 @@ 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 UseDeleteComponentDialogOptions { +export interface UseDeleteEntityDialogOptions { /** - * Called after a component has been successfully marked for deletion. Use it + * 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 UseDeleteComponentDialogResult { - /** Open the confirmation dialog for the given component entity. */ +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 a component from a listing (e.g. the project's Project Contents table) - * without leaving the page. + * 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 a refetch) instead of navigating away. Component-only by design; - * the project listing has no client-side delete for Resource entities. + * (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 useDeleteComponentDialog( - options?: UseDeleteComponentDialogOptions, -): UseDeleteComponentDialogResult { +export function useDeleteEntityDialog( + options?: UseDeleteEntityDialogOptions, +): UseDeleteEntityDialogResult { const { onDeleted } = options ?? {}; const openChoreoClient = useApi(openChoreoClientApiRef); const alertApi = useApi(alertApiRef); @@ -61,14 +68,15 @@ export function useDeleteComponentDialog( return; } const entityName = target.metadata.name; + const displayType = getEntityDisplayType(target.kind); setDeleting(true); setError(null); try { - await openChoreoClient.deleteComponent(target); + await performEntityDelete(openChoreoClient, target); alertApi.post({ - message: `Component "${entityName}" has been marked for deletion`, + message: `${displayType} "${entityName}" has been marked for deletion`, severity: 'success', display: 'transient', }); @@ -77,7 +85,7 @@ export function useDeleteComponentDialog( onDeleted?.(target); } catch (err) { const errorMessage = isForbiddenError(err) - ? 'You do not have permission to delete this component. Contact your administrator.' + ? `You do not have permission to delete this ${displayType.toLowerCase()}. Contact your administrator.` : getErrorMessage(err); setError(errorMessage); alertApi.post({ message: errorMessage, severity: 'error' }); @@ -90,10 +98,13 @@ export function useDeleteComponentDialog( () => ( diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx index 6e4629192..8e8407357 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useDeleteEntityMenuItems.tsx @@ -1,21 +1,18 @@ import { useState, useCallback, useMemo } from 'react'; -import { Typography } 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 { isMarkedForDeletion } from '../utils'; import { DeleteEntityDialog } from '../components'; import { - isSupportedKind, - mapKindToApiKind, -} from '../../ResourceDefinition/utils'; + getEntityDeleteCascadeNote, + getEntityDisplayType, + isDeletableEntityKind, + performEntityDelete, +} from './deleteEntityDispatch'; interface ExtraContextMenuItem { title: string; @@ -36,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. * @@ -86,17 +55,11 @@ export function useDeleteEntityMenuItems( 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); @@ -115,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`, @@ -163,13 +102,8 @@ export function useDeleteEntityMenuItems( } }, [ entity, - entityKind, entityName, entityDisplayType, - isComponent, - isProject, - isDomain, - isPlatformResource, openChoreoClient, alertApi, navigate, @@ -207,24 +141,10 @@ export function useDeleteEntityMenuItems( ]; }, [canDelete, entityDisplayType, handleOpenDialog, deletePermission]); - const cascadeNote = useMemo(() => { - if (isProject) { - return ( - - Note: All components within this project will also be deleted. - - ); - } - if (isDomain) { - return ( - - Note: All projects and components within this namespace will also be - deleted. - - ); - } - return undefined; - }, [isProject, isDomain]); + const cascadeNote = useMemo( + () => getEntityDeleteCascadeNote(entityKind), + [entityKind], + ); const DeleteConfirmationDialog: React.FC = useCallback( () => ( 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 36f1eeba8..d950493d2 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/index.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/index.ts @@ -1,11 +1,14 @@ // Hooks export { useDeleteEntityMenuItems, - useDeleteComponentDialog, + useDeleteEntityDialog, + usePendingDeletionOverlay, useEntityExistsCheck, + getEntityDisplayType, + isDeletableEntityKind, type DeletePermissionInfo, - type UseDeleteComponentDialogOptions, - type UseDeleteComponentDialogResult, + type UseDeleteEntityDialogOptions, + type UseDeleteEntityDialogResult, } from './hooks'; // Components @@ -13,11 +16,20 @@ export { DeletionBadge, DeletionWarning, DeleteEntityDialog, + RowDeleteButton, +} from './components'; +export type { + DeleteEntityDialogProps, + RowDeleteButtonProps, } from './components'; -export type { DeleteEntityDialogProps } 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 b9ba9666d..b0da43bf1 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.test.tsx @@ -69,7 +69,11 @@ const mockRequestDelete = jest.fn(); jest.mock('../../DeleteEntity', () => ({ isMarkedForDeletion: () => false, DeletionBadge: () => null, - useDeleteComponentDialog: () => ({ + RowDeleteButton: () => null, + entityDeletionKey: (entity: any) => + `${entity.kind}:${entity.metadata.namespace}/${entity.metadata.name}`, + markEntityForDeletionLocally: (entity: any) => entity, + useDeleteEntityDialog: () => ({ requestDelete: mockRequestDelete, DeleteDialog: () => null, }), diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx index f5dc22c75..34fb45d2b 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx @@ -3,7 +3,6 @@ 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'; -import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { Box, IconButton, @@ -27,8 +26,10 @@ import { type ProjectContentsOrderBy, } from '../hooks'; import { + entityDeletionKey, isMarkedForDeletion, - useDeleteComponentDialog, + markEntityForDeletionLocally, + useDeleteEntityDialog, } from '../../DeleteEntity'; import { shouldNavigateOnRowClick } from '../../../utils/shouldNavigateOnRowClick'; import { @@ -44,12 +45,6 @@ import { useProjectContentsCardStyles } from './styles'; const PAGE_SIZE = 5; const KIND_ORDER: ProjectContentKind[] = ['component', 'resource']; -/** Stable identity for a content row, independent of object reference. */ -const entityKey = (entity: Entity): string => - `${entity.kind.toLowerCase()}:${entity.metadata.namespace || 'default'}/${ - entity.metadata.name - }`; - /** * 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 @@ -58,22 +53,12 @@ const entityKey = (entity: Entity): string => * shows right away (the entity page gets the same effect by querying the OC * API directly). */ -const withOptimisticDeletion = (item: ProjectContentItem): ProjectContentItem => - isMarkedForDeletion(item.entity) - ? item - : { - ...item, - entity: { - ...item.entity, - metadata: { - ...item.entity.metadata, - annotations: { - ...(item.entity.metadata.annotations ?? {}), - [CHOREO_ANNOTATIONS.DELETION_TIMESTAMP]: new Date().toISOString(), - }, - }, - }, - }; +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); @@ -101,7 +86,7 @@ export const ProjectContentsCard = () => { }>({ by: 'createdAt', dir: 'desc' }); const [cursor, setCursor] = useState(); const [pageIndex, setPageIndex] = useState(0); - // Bumped after a component is marked for deletion to re-fetch the page. + // 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). @@ -133,18 +118,19 @@ export const ProjectContentsCard = () => { refreshToken, }); - // Row-level component delete. 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 component. + // 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(entityKey(deletedEntity)); + next.add(entityDeletionKey(deletedEntity)); return next; }); setRefreshToken(token => token + 1); }, []); - const { requestDelete, DeleteDialog } = useDeleteComponentDialog({ + const { requestDelete, DeleteDialog } = useDeleteEntityDialog({ onDeleted: handleDeleted, }); @@ -215,7 +201,7 @@ export const ProjectContentsCard = () => { canViewBindings, pipelineError, environmentsLoading, - onDeleteComponent: item => requestDelete(item.entity), + onDeleteItem: item => requestDelete(item.entity), }), [ pipelineEnvironments, @@ -232,7 +218,7 @@ export const ProjectContentsCard = () => { pendingDeletions.size === 0 ? page.items : page.items.map(item => - pendingDeletions.has(entityKey(item.entity)) + pendingDeletions.has(entityDeletionKey(item.entity)) ? withOptimisticDeletion(item) : item, ), diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx index c9dfc45a6..6aee0c7ea 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.test.tsx @@ -1,23 +1,25 @@ 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'; -// isMarkedForDeletion is annotation-driven; mock it so each test controls it. -const mockIsMarkedForDeletion = jest.fn(); -jest.mock('../../DeleteEntity', () => ({ - isMarkedForDeletion: (...args: any[]) => mockIsMarkedForDeletion(...args), -})); - 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' }, + metadata: { + name, + namespace: 'default', + annotations: options?.markedForDeletion + ? { [CHOREO_ANNOTATIONS.DELETION_TIMESTAMP]: '2026-01-01T00:00:00Z' } + : {}, + }, spec: {}, }, kind, @@ -31,11 +33,6 @@ function makeItem( } describe('RowActionsCell', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockIsMarkedForDeletion.mockReturnValue(false); - }); - it('renders a delete button for a component row', () => { render( { ).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(); @@ -60,18 +66,10 @@ describe('RowActionsCell', () => { await waitFor(() => expect(onDelete).toHaveBeenCalledWith(item)); }); - it('renders nothing for a resource row', () => { - const { container } = render( - , - ); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders nothing for a component already marked for deletion', () => { - mockIsMarkedForDeletion.mockReturnValue(true); + it('renders nothing for a row already marked for deletion', () => { const { container } = render( , ); diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx index 6e8ebe11a..8ea6ff9d9 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/RowActionsCell.tsx @@ -1,42 +1,17 @@ -import { type MouseEvent } from 'react'; -import { IconButton, Tooltip } from '@material-ui/core'; -import DeleteOutlineIcon from '@material-ui/icons/DeleteOutline'; -import { isMarkedForDeletion } from '../../DeleteEntity'; +import { RowDeleteButton } from '../../DeleteEntity'; import { type ProjectContentItem } from '../hooks'; interface RowActionsCellProps { item: ProjectContentItem; - /** Open the delete-confirmation dialog for this component. */ + /** Open the delete-confirmation dialog for this row's entity. */ onDelete: (item: ProjectContentItem) => void; } /** - * Per-row delete control for the Project Contents table. - * - * Only components are deletable from the listing — Resource rows have no - * client-side delete, and a row already marked for deletion shows nothing. - * The click is stopped from bubbling so the row's navigate-on-click doesn't - * fire. + * 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) => { - if (item.kind !== 'component' || isMarkedForDeletion(item.entity)) { - return null; - } - - const handleDelete = (event: MouseEvent) => { - event.stopPropagation(); - onDelete(item); - }; - - return ( - - - - - - ); -}; +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 84bb464b6..07f6655ca 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/columns.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/columns.tsx @@ -124,8 +124,8 @@ interface BuildColumnsArgs { pipelineError: unknown; /** Pipeline environments / permission still loading — skeleton the column. */ environmentsLoading: boolean; - /** Open the delete-confirmation dialog for a component row. */ - onDeleteComponent: (item: ProjectContentItem) => void; + /** Open the delete-confirmation dialog for a row. */ + onDeleteItem: (item: ProjectContentItem) => void; } export function buildProjectContentColumns({ @@ -133,7 +133,7 @@ export function buildProjectContentColumns({ canViewBindings, pipelineError, environmentsLoading, - onDeleteComponent, + onDeleteItem, }: BuildColumnsArgs): TableColumn[] { return [ { @@ -206,7 +206,7 @@ export function buildProjectContentColumns({ cellStyle: { textAlign: 'right', paddingRight: 8 }, headerStyle: { textAlign: 'right' }, render: item => ( - + ), }, ]; 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 {