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