move to useOnClick and migrate entity URL copy to blueprint

Signed-off-by: Mark Dunphy <markd@spotify.com>
This commit is contained in:
Mark Dunphy
2025-03-20 16:12:40 -04:00
parent e0a4ae8e5c
commit 16aeb3f10a
8 changed files with 117 additions and 55 deletions
@@ -16,68 +16,77 @@
import React from 'react';
import {
DialogApiDialog,
ExtensionBoundary,
coreExtensionData,
createExtensionBlueprint,
dialogApiRef,
ApiHolder,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import MenuItem from '@material-ui/core/MenuItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
/** @alpha */
export type FactoryLoaderParams = {
loader: () => Promise<JSX.Element>;
};
/** @alpha */
export type FactoryHrefParams =
| {
title: string;
icon: JSX.Element;
useTitle: () => string;
icon: React.JSX.Element;
useHref: () => string;
}
| {
title: string;
icon: JSX.Element;
useTitle: () => string;
icon: React.JSX.Element;
href: string;
};
/** @alpha */
export type FactoryDialogParams = {
dialogLoader: () => Promise<
({ dialog }: { dialog: DialogApiDialog }) => JSX.Element
>;
title: string;
icon: JSX.Element;
useOnClick: ({
apis,
}: {
apis: ApiHolder;
}) => React.MouseEventHandler<HTMLLIElement>;
useTitle: () => string;
icon: React.JSX.Element;
};
/** @alpha */
export type EntityContextMenuItemParams =
| FactoryLoaderParams
| FactoryHrefParams
| FactoryDialogParams;
export type ContextMenuItemProps = {
onClose: () => void;
};
export type ContextMenuItemComponent = (
props: ContextMenuItemProps,
) => React.JSX.Element;
export const contextMenuItemComponentDataRef =
createExtensionDataRef<ContextMenuItemComponent>().with({
id: 'catalog.contextMenuItemComponent',
});
/** @alpha */
export const EntityContextMenuItemBlueprint = createExtensionBlueprint({
kind: 'entity-context-menu-item',
attachTo: { id: 'page:catalog/entity', input: 'contextMenuItems' },
output: [coreExtensionData.reactElement],
*factory(params: EntityContextMenuItemParams, { node, apis }) {
const loaderFactory = () => {
if ('loader' in params) {
return params.loader;
}
output: [contextMenuItemComponentDataRef],
*factory(params: EntityContextMenuItemParams, { apis }) {
const loaderFactory = (): ContextMenuItemComponent => {
if ('useOnClick' in params) {
return ({ onClose }) => {
const onClick = params.useOnClick({ apis });
const title = params.useTitle();
if ('dialogLoader' in params) {
const dialogApi = apis.get(dialogApiRef);
return async () => {
const Dialog = await params.dialogLoader();
return (
<MenuItem onClick={() => dialogApi?.show(Dialog)}>
<MenuItem
onClick={e => {
onClick(e);
onClose();
}}
>
<ListItemIcon>{params.icon}</ListItemIcon>
<ListItemText primary={params.title} />
<ListItemText primary={title} />
</MenuItem>
);
};
@@ -85,20 +94,19 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({
const useHref = 'useHref' in params ? params.useHref : () => params.href;
return async () => {
return () => {
const href = useHref();
const title = params.useTitle();
return (
<MenuItem component="a" href={href}>
<ListItemIcon>{params.icon}</ListItemIcon>
<ListItemText primary={params.title} />
<ListItemText primary={title} />
</MenuItem>
);
};
};
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, loaderFactory()),
);
yield contextMenuItemComponentDataRef(loaderFactory());
},
});
@@ -24,9 +24,11 @@ export { EntityHeaderBlueprint } from './EntityHeaderBlueprint';
export { defaultEntityContentGroups } from './extensionData';
export type { EntityCardType } from './extensionData';
export {
contextMenuItemComponentDataRef,
EntityContextMenuItemBlueprint,
type ContextMenuItemProps,
type ContextMenuItemComponent,
type FactoryHrefParams,
type FactoryLoaderParams,
type FactoryDialogParams,
type EntityContextMenuItemParams,
} from './EntityContextMenuItemBlueprint';
@@ -55,6 +55,7 @@ import {
import { EntityLabels } from '../EntityLabels';
import { EntityContextMenu } from '../../../components/EntityContextMenu';
import { rootRouteRef, unregisterRedirectRouteRef } from '../../../routes';
import { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha';
function headerProps(
paramKind: string | undefined,
@@ -178,7 +179,7 @@ export function EntityHeader(props: {
UNSTABLE_contextMenuOptions?: {
disableUnregister: boolean | 'visible' | 'hidden' | 'disable';
};
extraMenuItems?: JSX.Element[];
extraMenuItems?: ContextMenuItemComponent[];
/**
* An array of relation types used to determine the parent entities in the hierarchy.
* These relations are prioritized in the order provided, allowing for flexible
@@ -0,0 +1,57 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { EntityContextMenuItemBlueprint } from '@backstage/plugin-catalog-react/alpha';
import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone';
import useCopyToClipboard from 'react-use/esm/useCopyToClipboard';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogTranslationRef } from './translation';
export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make(
{
name: 'copy-entity-url',
params: {
icon: <FileCopyTwoToneIcon fontSize="small" />,
useTitle: () => {
const { t } = useTranslationRef(catalogTranslationRef);
return t('entityContextMenu.copyURLMenuTitle');
},
useOnClick: () => {
const [copyState, copyToClipboard] = useCopyToClipboard();
const alertApi = useApi(alertApiRef);
const { t } = useTranslationRef(catalogTranslationRef);
React.useEffect(() => {
if (!copyState.error && copyState.value) {
alertApi.post({
message: t('entityContextMenu.copiedMessage'),
severity: 'info',
display: 'transient',
});
}
}, [copyState, alertApi, t]);
return async () => {
copyToClipboard(window.location.toString());
};
},
},
},
);
export default [copyEntityUrlContextMenuItem];
+3 -2
View File
@@ -31,6 +31,7 @@ import {
EntityHeaderBlueprint,
EntityContentBlueprint,
defaultEntityContentGroups,
contextMenuItemComponentDataRef,
} from '@backstage/plugin-catalog-react/alpha';
import { rootRouteRef } from '../routes';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
@@ -72,7 +73,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
EntityContentBlueprint.dataRefs.filterExpression.optional(),
EntityContentBlueprint.dataRefs.group.optional(),
]),
contextMenuItems: createExtensionInput([coreExtensionData.reactElement]),
contextMenuItems: createExtensionInput([contextMenuItemComponentDataRef]),
},
config: {
schema: {
@@ -90,7 +91,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
const { EntityLayout } = await import('./components/EntityLayout');
const menuItems = inputs.contextMenuItems.map(item =>
item.get(coreExtensionData.reactElement),
item.get(contextMenuItemComponentDataRef),
);
type Groups = Record<
+2
View File
@@ -34,6 +34,7 @@ import navItems from './navItems';
import entityCards from './entityCards';
import entityContents from './entityContents';
import searchResultItems from './searchResultItems';
import contextMenuItems from './contextMenuItems';
/** @alpha */
export default createFrontendPlugin({
@@ -55,6 +56,7 @@ export default createFrontendPlugin({
...navItems,
...entityCards,
...entityContents,
...contextMenuItems,
...searchResultItems,
],
});
@@ -1,5 +1,5 @@
/*
* Copyright 2023 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -25,7 +25,6 @@ import Tooltip from '@material-ui/core/Tooltip';
import { Theme, makeStyles } from '@material-ui/core/styles';
import BugReportIcon from '@material-ui/icons/BugReport';
import MoreVert from '@material-ui/icons/MoreVert';
import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone';
import { SyntheticEvent, useEffect, useState } from 'react';
import { IconComponent } from '@backstage/core-plugin-api';
import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
@@ -35,6 +34,7 @@ import { useApi, alertApiRef } from '@backstage/core-plugin-api';
import useCopyToClipboard from 'react-use/esm/useCopyToClipboard';
import { catalogTranslationRef } from '../../alpha/translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import type { ContextMenuItemComponent } from '@backstage/plugin-catalog-react/alpha';
/** @public */
export type EntityContextMenuClassKey = 'button';
@@ -61,7 +61,7 @@ interface ExtraContextMenuItem {
interface EntityContextMenuProps {
UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[];
UNSTABLE_contextMenuOptions?: UnregisterEntityOptions;
extraMenuItems?: JSX.Element[];
extraMenuItems?: ContextMenuItemComponent[];
onUnregisterEntity: () => void;
onInspectEntity: () => void;
}
@@ -147,7 +147,6 @@ export function EntityContextMenu(props: EntityContextMenuProps) {
>
<MenuList autoFocusItem={Boolean(anchorEl)}>
{extraItems}
{extraMenuItems}
<UnregisterEntity
unregisterEntityOptions={UNSTABLE_contextMenuOptions}
isUnregisterAllowed={isAllowed}
@@ -165,17 +164,9 @@ export function EntityContextMenu(props: EntityContextMenuProps) {
</ListItemIcon>
<ListItemText primary={t('entityContextMenu.inspectMenuTitle')} />
</MenuItem>
<MenuItem
onClick={() => {
onClose();
copyToClipboard(window.location.toString());
}}
>
<ListItemIcon>
<FileCopyTwoToneIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary={t('entityContextMenu.copyURLMenuTitle')} />
</MenuItem>
{extraMenuItems?.map(ExtraMenuItem => (
<ExtraMenuItem onClose={onClose} />
))}
</MenuList>
</Popover>
</>