diff --git a/.changeset/noble-harbor-persist.md b/.changeset/noble-harbor-persist.md new file mode 100644 index 00000000..c8d40571 --- /dev/null +++ b/.changeset/noble-harbor-persist.md @@ -0,0 +1,5 @@ +--- +'@giantswarm/backstage-plugin-gs': patch +--- + +Replace portal-based EntityHeaderIcon with EntityHeaderBlueprint custom header so the entity icon persists across all tabs on catalog entity pages. diff --git a/plugins/gs/src/components/catalog/CustomEntityHeader/CustomEntityHeader.tsx b/plugins/gs/src/components/catalog/CustomEntityHeader/CustomEntityHeader.tsx new file mode 100644 index 00000000..30420a47 --- /dev/null +++ b/plugins/gs/src/components/catalog/CustomEntityHeader/CustomEntityHeader.tsx @@ -0,0 +1,351 @@ +/** + * Custom entity page header that mirrors the upstream EntityHeader + * from @backstage/plugin-catalog, with the addition of an entity icon + * rendered from the GS icon URL annotation. + * + * This exists because EntityHeaderBlueprint replaces the entire header, + * so we must reproduce the full upstream behavior. The implementation + * follows the upstream code at: + * @backstage/plugin-catalog/dist/alpha/components/EntityHeader/EntityHeader.esm.js + * @backstage/plugin-catalog/dist/alpha/components/EntityLabels/EntityLabels.esm.js + * @backstage/plugin-catalog/dist/components/EntityContextMenu/EntityContextMenu.esm.js + */ +import { useState, useCallback, useEffect, Fragment } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import useAsync from 'react-use/esm/useAsync'; +import useCopyToClipboard from 'react-use/esm/useCopyToClipboard'; +import { makeStyles } from '@material-ui/core/styles'; +import Box from '@material-ui/core/Box'; +import IconButton from '@material-ui/core/IconButton'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import MenuItem from '@material-ui/core/MenuItem'; +import MenuList from '@material-ui/core/MenuList'; +import Popover from '@material-ui/core/Popover'; +import Tooltip from '@material-ui/core/Tooltip'; +import BugReportIcon from '@material-ui/icons/BugReport'; +import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone'; +import MoreVert from '@material-ui/icons/MoreVert'; +import { Header, HeaderLabel, Breadcrumbs } from '@backstage/core-components'; +import { + useRouteRefParams, + useApi, + alertApiRef, +} from '@backstage/core-plugin-api'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { DEFAULT_NAMESPACE, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + useAsyncEntity, + entityRouteRef, + catalogApiRef, + EntityDisplayName, + EntityRefLink, + EntityRefLinks, + FavoriteEntity, + InspectEntityDialog, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import { catalogTranslationRef } from '@backstage/plugin-catalog/alpha'; +import type { Entity } from '@backstage/catalog-model'; +import { getIconUrlFromEntity } from '../../utils/entity'; +import { injectHeaderIcon, removeHeaderIcon } from '../EntityHeaderIcon'; + +// --------------------------------------------------------------------------- +// Helpers (mirrored from upstream EntityHeader) +// --------------------------------------------------------------------------- + +function headerProps( + paramKind: string, + paramNamespace: string, + paramName: string, + entity: Entity | undefined, +) { + const kind = paramKind ?? entity?.kind ?? ''; + const namespace = paramNamespace ?? entity?.metadata.namespace ?? ''; + const name = + entity?.metadata.title ?? paramName ?? entity?.metadata.name ?? ''; + return { + headerTitle: `${name}${namespace && namespace !== DEFAULT_NAMESPACE ? ` in ${namespace}` : ''}`, + headerType: (() => { + let t = kind.toLocaleLowerCase('en-US'); + if (entity?.spec && 'type' in entity.spec) { + t += ' \u2014 '; + t += (entity.spec.type as string).toLocaleLowerCase('en-US'); + } + return t; + })(), + }; +} + +function findParentRelation( + entityRelations: Entity['relations'], + relationTypes: string[], +) { + for (const type of relationTypes) { + const found = (entityRelations ?? []).find(r => r.type === type); + if (found) return found; + } + return null; +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +const useBreadcrumbStyles = makeStyles(theme => ({ + breadcrumbs: { + color: theme.page.fontColor, + fontSize: theme.typography.caption.fontSize, + textTransform: 'uppercase', + marginTop: theme.spacing(1), + opacity: 0.8, + '& span ': { + color: theme.page.fontColor, + textDecoration: 'underline', + textUnderlineOffset: '3px', + }, + }, +})); + +function EntityHeaderTitle() { + const { entity } = useAsyncEntity(); + const { kind, namespace, name } = useRouteRefParams(entityRouteRef); + const { headerTitle: title } = headerProps(kind, namespace, name, entity); + return ( + + + {entity ? : title} + + {entity && } + + ); +} + +function EntityHeaderSubtitle(props: { parentEntityRelations?: string[] }) { + const { parentEntityRelations } = props; + const classes = useBreadcrumbStyles(); + const { entity } = useAsyncEntity(); + const { name } = useRouteRefParams(entityRouteRef); + const parentEntity = findParentRelation( + entity?.relations ?? [], + parentEntityRelations ?? [], + ); + const catalogApi = useApi(catalogApiRef); + const { value: ancestorEntity } = useAsync(async () => { + if (parentEntity) { + return findParentRelation( + (await catalogApi.getEntityByRef(parentEntity.targetRef))?.relations, + parentEntityRelations ?? [], + ); + } + return null; + }, [parentEntity, catalogApi]); + + if (!parentEntity) return null; + + return ( + + {ancestorEntity && ( + + )} + + {name} + + ); +} + +function EntityLabels({ entity }: { entity: Entity }) { + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + const { t } = useTranslationRef(catalogTranslationRef); + return ( + + {ownedByRelations.length > 0 && ( + + } + /> + )} + {entity.spec?.lifecycle && ( + + )} + + ); +} + +const useContextMenuStyles = makeStyles( + theme => ({ + button: { color: theme.page.fontColor }, + }), + { name: 'PluginCatalogEntityContextMenu' }, +); + +function EntityContextMenu(props: { onInspectEntity: () => void }) { + const { onInspectEntity } = props; + const { t } = useTranslationRef(catalogTranslationRef); + const [anchorEl, setAnchorEl] = useState(); + const classes = useContextMenuStyles(); + const alertApi = useApi(alertApiRef); + const [copyState, copyToClipboard] = useCopyToClipboard(); + + useEffect(() => { + if (!copyState.error && copyState.value) { + alertApi.post({ + message: t('entityContextMenu.copiedMessage'), + severity: 'info', + display: 'transient', + }); + } + }, [copyState, alertApi, t]); + + const onOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const onClose = () => setAnchorEl(undefined); + + return ( + + + + + + + + + { + onClose(); + onInspectEntity(); + }} + > + + + + + + { + onClose(); + copyToClipboard(window.location.toString()); + }} + > + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Main header component +// --------------------------------------------------------------------------- + +export function CustomEntityHeader() { + const { entity } = useAsyncEntity(); + const { kind, namespace, name } = useRouteRefParams(entityRouteRef); + const { headerTitle: entityFallbackText, headerType: type } = headerProps( + kind, + namespace, + name, + entity, + ); + + const [searchParams, setSearchParams] = useSearchParams(); + const selectedInspectEntityDialogTab = searchParams.get('inspect'); + const setInspectEntityDialogTab = useCallback( + (newTab: string) => setSearchParams(`inspect=${newTab}`), + [setSearchParams], + ); + const openInspectEntityDialog = useCallback( + () => setSearchParams('inspect'), + [setSearchParams], + ); + const closeInspectEntityDialog = useCallback( + () => setSearchParams(), + [setSearchParams], + ); + const inspectDialogOpen = typeof selectedInspectEntityDialogTab === 'string'; + + const iconUrl = entity ? getIconUrlFromEntity(entity) : undefined; + + useEffect(() => { + if (!iconUrl) { + removeHeaderIcon(); + return undefined; + } + + injectHeaderIcon(iconUrl); + const timeoutId = setTimeout(() => injectHeaderIcon(iconUrl), 50); + + return () => { + clearTimeout(timeoutId); + removeHeaderIcon(); + }; + }, [iconUrl]); + + return ( +
} + subtitle={} + > + {entity && ( + + + + + + )} +
+ ); +} diff --git a/plugins/gs/src/components/catalog/CustomEntityHeader/index.ts b/plugins/gs/src/components/catalog/CustomEntityHeader/index.ts new file mode 100644 index 00000000..7784e345 --- /dev/null +++ b/plugins/gs/src/components/catalog/CustomEntityHeader/index.ts @@ -0,0 +1 @@ +export { CustomEntityHeader } from './CustomEntityHeader'; diff --git a/plugins/gs/src/components/catalog/EntityHeaderIcon/EntityHeaderIcon.tsx b/plugins/gs/src/components/catalog/EntityHeaderIcon/EntityHeaderIcon.tsx index ff7244bf..1a2beb63 100644 --- a/plugins/gs/src/components/catalog/EntityHeaderIcon/EntityHeaderIcon.tsx +++ b/plugins/gs/src/components/catalog/EntityHeaderIcon/EntityHeaderIcon.tsx @@ -1,89 +1,60 @@ -import { useEffect, useState, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { useAsyncEntity } from '@backstage/plugin-catalog-react'; -import { getIconUrlFromEntity } from '../../utils/entity'; - const ICON_CONTAINER_ID = 'gs-entity-header-icon-container'; +function createIconElement(iconUrl: string): HTMLDivElement { + const wrapper = document.createElement('div'); + wrapper.style.display = 'flex'; + wrapper.style.alignItems = 'center'; + wrapper.style.justifyContent = 'center'; + wrapper.style.width = '64px'; + wrapper.style.height = '64px'; + wrapper.style.borderRadius = '6px'; + wrapper.style.backgroundColor = 'rgba(255, 255, 255, 0.5)'; + wrapper.style.marginRight = '16px'; + wrapper.style.flexShrink = '0'; + + const img = document.createElement('img'); + img.src = iconUrl; + img.alt = ''; + img.style.width = '50px'; + img.style.height = '50px'; + img.style.objectFit = 'contain'; + + wrapper.appendChild(img); + return wrapper; +} + /** - * Renders a custom icon in the entity page header via a DOM portal. - * Finds the page header element and injects the icon before its first child. - * Mount this component anywhere inside an entity page — e.g. in a content layout. + * Injects (or updates) the entity icon into the page header via DOM + * manipulation. The icon is placed as a flex sibling before the first + * child of `main > header`. */ -export const EntityHeaderIcon = () => { - const { entity } = useAsyncEntity(); - const [portalContainer, setPortalContainer] = useState( - null, - ); - const containerRef = useRef(null); +export function injectHeaderIcon(iconUrl: string): void { + const header = document.querySelector('main > header'); + if (!header || !header.firstElementChild) { + return; + } - const iconUrl = entity ? getIconUrlFromEntity(entity) : undefined; + let container = document.getElementById( + ICON_CONTAINER_ID, + ) as HTMLDivElement | null; - useEffect(() => { - if (!iconUrl) { - return undefined; + if (container) { + // Update existing icon if URL changed + const img = container.querySelector('img'); + if (img && img.src !== iconUrl) { + img.src = iconUrl; } - - const setupContainer = () => { - const header = document.querySelector('main > header'); - if (!header || !header.firstElementChild) { - return; - } - - const firstChild = header.firstElementChild; - - let container = document.getElementById(ICON_CONTAINER_ID); - if (!container) { - container = document.createElement('div'); - container.id = ICON_CONTAINER_ID; - container.style.display = 'contents'; - header.insertBefore(container, firstChild); - } - containerRef.current = container as HTMLDivElement; - setPortalContainer(container as HTMLDivElement); - }; - - setupContainer(); - const timeoutId = setTimeout(setupContainer, 100); - - return () => { - clearTimeout(timeoutId); - if (containerRef.current && containerRef.current.parentNode) { - containerRef.current.parentNode.removeChild(containerRef.current); - containerRef.current = null; - } - }; - }, [iconUrl]); - - if (!iconUrl || !portalContainer) { - return null; + return; } - const iconElement = ( -
- -
- ); - - return createPortal(iconElement, portalContainer); -}; + container = document.createElement('div'); + container.id = ICON_CONTAINER_ID; + container.style.display = 'contents'; + container.appendChild(createIconElement(iconUrl)); + header.insertBefore(container, header.firstElementChild); +} + +/** Removes the injected header icon from the DOM. */ +export function removeHeaderIcon(): void { + document.getElementById(ICON_CONTAINER_ID)?.remove(); +} diff --git a/plugins/gs/src/components/catalog/EntityHeaderIcon/index.ts b/plugins/gs/src/components/catalog/EntityHeaderIcon/index.ts index fcb24ccd..ecdd532f 100644 --- a/plugins/gs/src/components/catalog/EntityHeaderIcon/index.ts +++ b/plugins/gs/src/components/catalog/EntityHeaderIcon/index.ts @@ -1 +1 @@ -export { EntityHeaderIcon } from './EntityHeaderIcon'; +export { injectHeaderIcon, removeHeaderIcon } from './EntityHeaderIcon'; diff --git a/plugins/gs/src/components/catalog/HelmChartContentLayout/HelmChartContentLayout.tsx b/plugins/gs/src/components/catalog/HelmChartContentLayout/HelmChartContentLayout.tsx index 2c6932b1..49b1b45c 100644 --- a/plugins/gs/src/components/catalog/HelmChartContentLayout/HelmChartContentLayout.tsx +++ b/plugins/gs/src/components/catalog/HelmChartContentLayout/HelmChartContentLayout.tsx @@ -2,7 +2,6 @@ import { Fragment } from 'react'; import { makeStyles } from '@material-ui/core/styles'; import type { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha'; import { EntityChartProvider } from '../EntityChartContext'; -import { EntityHeaderIcon } from '../EntityHeaderIcon'; const useStyles = makeStyles(theme => ({ root: { @@ -73,7 +72,6 @@ export function HelmChartContentLayout(props: EntityContentLayoutProps) { return ( -
{infoCards.length > 0 ? (
diff --git a/plugins/gs/src/components/utils/entity.ts b/plugins/gs/src/components/utils/entity.ts index f7d4acfb..de85a8c2 100644 --- a/plugins/gs/src/components/utils/entity.ts +++ b/plugins/gs/src/components/utils/entity.ts @@ -88,3 +88,7 @@ export const isEntityHelmChartTagged = (entity: Entity) => { const tags = entity.metadata.tags ?? []; return tags.includes('helmchart'); }; + +export const isEntityWithIcon = (entity: Entity) => { + return Boolean(getIconUrlFromEntity(entity)); +}; diff --git a/plugins/gs/src/plugin.tsx b/plugins/gs/src/plugin.tsx index 178332a8..f274d2b1 100644 --- a/plugins/gs/src/plugin.tsx +++ b/plugins/gs/src/plugin.tsx @@ -8,6 +8,7 @@ import { EntityCardBlueprint, EntityContentBlueprint, EntityContentLayoutBlueprint, + EntityHeaderBlueprint, } from '@backstage/plugin-catalog-react/alpha'; import { FormFieldBlueprint, @@ -38,6 +39,7 @@ import { isEntityHelmChartTagged, isEntityInstallationResource, isEntityKratixResource, + isEntityWithIcon, } from './components/utils/entity'; import { gsAuthProvidersApiRef, @@ -314,6 +316,19 @@ const helmChartContentLayout = EntityContentLayoutBlueprint.make({ }, }); +// Custom entity header with icon for entities that have a GS icon annotation +const iconEntityHeader = EntityHeaderBlueprint.make({ + name: 'gs-icon', + params: { + filter: entity => (entity ? isEntityWithIcon(entity) : false), + loader: async () => { + const { CustomEntityHeader } = + await import('./components/catalog/CustomEntityHeader'); + return ; + }, + }, +}); + // Scaffolder form field extensions const chartPickerFormField = FormFieldBlueprint.make({ name: 'chart-picker', @@ -554,6 +569,8 @@ export const gsPlugin = createFrontendPlugin({ kratixResourcesEntityContent, // Entity content layout helmChartContentLayout, + // Entity header with icon + iconEntityHeader, // Scaffolder form fields chartPickerFormField, chartTagPickerFormField,