implement useProps api

Signed-off-by: Mark Dunphy <markd@spotify.com>
This commit is contained in:
Mark Dunphy
2025-03-27 10:20:37 -04:00
parent 233afd2ef3
commit 50ce1645ab
5 changed files with 114 additions and 105 deletions
+8 -3
View File
@@ -11,6 +11,8 @@ import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
import { MenuItemProps } from '@material-ui/core/MenuItem';
import { default as React_2 } from 'react';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -359,9 +361,12 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{
}>;
// @alpha (undocumented)
export type EntityContextMenuItemParams =
| FactoryHrefParams
| FactoryDialogParams;
export type EntityContextMenuItemParams = {
useProps: () => Omit<MenuItemProps, 'onClick'> & {
onClick?: () => Promise<void>;
};
icon: React_2.JSX.Element;
};
// @alpha (undocumented)
export const EntityHeaderBlueprint: ExtensionBlueprint<{
@@ -16,28 +16,24 @@
import React from 'react';
import { EntityContextMenuItemBlueprint } from './EntityContextMenuItemBlueprint';
import { createRouteRef, useRouteRef } from '@backstage/frontend-plugin-api';
describe('EntityContextMenuItemBlueprint', () => {
const routeRef = createRouteRef();
const data = [
{
useTitle: () => 'Test',
href: '/somewhere',
icon: <span>Test</span>,
useProps: () => ({
title: 'Test',
href: '/somewhere',
component: 'a',
disabled: true,
}),
},
{
useTitle: () => 'Test',
useHref: () => {
const r = useRouteRef(routeRef) ?? (() => '/somewhere');
return r();
},
icon: <span>Test</span>,
},
{
useTitle: () => 'TestDialog',
useOnClick: () => () => true,
icon: <span>Test</span>,
useProps: () => ({
title: 'Test',
onClick: async () => {},
}),
},
];
@@ -20,7 +20,7 @@ import {
createExtensionDataRef,
ExtensionBoundary,
} from '@backstage/frontend-plugin-api';
import MenuItem from '@material-ui/core/MenuItem';
import MenuItem, { MenuItemProps } from '@material-ui/core/MenuItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
@@ -48,9 +48,12 @@ export type FactoryDialogParams = {
};
/** @alpha */
export type EntityContextMenuItemParams =
| FactoryHrefParams
| FactoryDialogParams;
export type EntityContextMenuItemParams = {
useProps: () => Omit<MenuItemProps, 'onClick'> & {
onClick?: () => Promise<void>;
};
icon: React.JSX.Element;
};
/** @alpha */
export type ContextMenuItemProps = {
@@ -75,36 +78,25 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({
output: [contextMenuItemComponentDataRef],
*factory(params: EntityContextMenuItemParams, { node }) {
const loader = async (): Promise<ContextMenuItemComponent> => {
if ('useOnClick' in params) {
return ({ onClose }) => {
const onClick = params.useOnClick();
const title = params.useTitle();
const disabled = params.useIsDisabled?.() ?? false;
return ({ onClose }) => {
const {
children,
button,
title,
onClick: onClickProp,
...menuItemProps
} = params.useProps();
let onClick;
return (
<MenuItem
disabled={disabled}
onClick={e => {
onClose();
onClick(e);
}}
>
<ListItemIcon>{params.icon}</ListItemIcon>
<ListItemText primary={title} />
</MenuItem>
);
};
}
const useHref = 'useHref' in params ? params.useHref : () => params.href;
return () => {
const href = useHref();
const title = params.useTitle();
const disabled = params.useIsDisabled?.() ?? false;
if (onClickProp !== undefined) {
onClick = async () => {
await onClickProp();
onClose();
};
}
return (
<MenuItem disabled={disabled} component="a" href={href}>
<MenuItem {...menuItemProps} onClick={onClick}>
<ListItemIcon>{params.icon}</ListItemIcon>
<ListItemText primary={title} />
</MenuItem>
+38 -42
View File
@@ -43,11 +43,7 @@ export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make(
name: 'copy-entity-url',
params: {
icon: <FileCopyTwoToneIcon fontSize="small" />,
useTitle: () => {
const { t } = useTranslationRef(catalogTranslationRef);
return t('entityContextMenu.copyURLMenuTitle');
},
useOnClick: () => {
useProps: () => {
const [copyState, copyToClipboard] = useCopyToClipboard();
const alertApi = useApi(alertApiRef);
const { t } = useTranslationRef(catalogTranslationRef);
@@ -62,8 +58,11 @@ export const copyEntityUrlContextMenuItem = EntityContextMenuItemBlueprint.make(
}
}, [copyState, alertApi, t]);
return async () => {
copyToClipboard(window.location.toString());
return {
title: t('entityContextMenu.copyURLMenuTitle'),
onClick: async () => {
copyToClipboard(window.location.toString());
},
};
},
},
@@ -75,15 +74,15 @@ export const inspectEntityContextMenuItem = EntityContextMenuItemBlueprint.make(
name: 'inspect-entity',
params: {
icon: <BugReportIcon fontSize="small" />,
useTitle: () => {
const { t } = useTranslationRef(catalogTranslationRef);
return t('entityContextMenu.inspectMenuTitle');
},
useOnClick: () => {
useProps: () => {
const [_, setSearchParams] = useSearchParams();
const { t } = useTranslationRef(catalogTranslationRef);
return () => {
setSearchParams('inspect');
return {
title: t('entityContextMenu.inspectMenuTitle'),
onClick: async () => {
setSearchParams('inspect');
},
};
},
},
@@ -95,40 +94,37 @@ export const unregisterEntityContextMenuItem =
name: 'unregister-entity',
params: {
icon: <CancelIcon fontSize="small" />,
useTitle: () => {
const { t } = useTranslationRef(catalogTranslationRef);
return t('entityContextMenu.unregisterMenuTitle');
},
useIsDisabled: () => {
const unregisterPermission = useEntityPermission(
catalogEntityDeletePermission,
);
return !unregisterPermission.allowed;
},
useOnClick: () => {
useProps: () => {
const { entity } = useEntity();
const dialogApi = useApi(dialogApiRef);
const navigate = useNavigate();
const catalogRoute = useRouteRef(rootRouteRef);
const { t } = useTranslationRef(catalogTranslationRef);
const unregisterRedirectRoute = useRouteRef(unregisterRedirectRouteRef);
const unregisterPermission = useEntityPermission(
catalogEntityDeletePermission,
);
return async () => {
dialogApi.showModal(({ dialog }: { dialog: DialogApiDialog }) => (
<UnregisterEntityDialog
open
entity={entity}
onClose={() => dialog.close()}
onConfirm={() => {
dialog.close();
navigate(
unregisterRedirectRoute
? unregisterRedirectRoute()
: catalogRoute(),
);
}}
/>
));
return {
title: t('entityContextMenu.unregisterMenuTitle'),
disabled: !unregisterPermission.allowed,
onClick: async () => {
dialogApi.showModal(({ dialog }: { dialog: DialogApiDialog }) => (
<UnregisterEntityDialog
open
entity={entity}
onClose={() => dialog.close()}
onConfirm={() => {
dialog.close();
navigate(
unregisterRedirectRoute
? unregisterRedirectRoute()
: catalogRoute(),
);
}}
/>
));
},
};
},
},
+35 -15
View File
@@ -605,15 +605,26 @@ describe('Entity page', () => {
});
it.each([
{ useIsDisabled: () => true, href: '/somewhere' },
{ useIsDisabled: () => false, href: '/somewhere' },
{ useIsDisabled: () => true, useHref: () => '/somewhere' },
{ useIsDisabled: () => false, useHref: () => '/somewhere' },
{
useProps: () => ({
title: 'Test Title',
href: '/somewhere',
disabled: true,
component: 'a',
}),
},
{
useProps: () => ({
title: 'Test Title',
href: '/somewhere',
disabled: false,
component: 'a',
}),
},
])('should render an href based context menu item', async params => {
const menuItem = EntityContextMenuItemBlueprint.make({
name: 'test-href',
params: {
useTitle: () => 'Test Title',
icon: <span>Test Icon</span>,
...params,
},
@@ -645,6 +656,7 @@ describe('Entity page', () => {
},
},
);
const { disabled } = params.useProps();
await waitFor(async () => {
await userEvent.click(screen.getByTestId('menu-button'));
@@ -652,21 +664,29 @@ describe('Entity page', () => {
expect(screen.getByText('Test Icon')).toBeInTheDocument();
const anchor = screen.getByText('Test Title').closest('a');
expect(anchor).toHaveAttribute('href', '/somewhere');
expect(anchor).toHaveAttribute(
'aria-disabled',
params.useIsDisabled().toString(),
);
expect(anchor).toHaveAttribute('aria-disabled', disabled.toString());
});
});
it.each([
{ useIsDisabled: () => true, useOnClick: () => onClickMock },
{ useIsDisabled: () => false, useOnClick: () => onClickMock },
])('should render a useOnClick based context menu item', async params => {
{
useProps: () => ({
title: 'Test Title',
onClick: onClickMock,
disabled: true,
}),
},
{
useProps: () => ({
title: 'Test Title',
onClick: onClickMock,
disabled: false,
}),
},
])('should render an onClick based context menu item', async params => {
const menuItem = EntityContextMenuItemBlueprint.make({
name: 'test-href',
name: 'test-click',
params: {
useTitle: () => 'Test Title',
icon: <span>Test Icon</span>,
...params,
},
@@ -674,7 +694,6 @@ describe('Entity page', () => {
const tester = createExtensionTester(
Object.assign({ namespace: 'catalog' }, catalogEntityPage),
).add(menuItem);
const disabled = params.useIsDisabled();
renderInTestApp(
<TestApiProvider
@@ -700,6 +719,7 @@ describe('Entity page', () => {
},
);
const { disabled } = params.useProps();
await waitFor(async () => {
await userEvent.click(screen.getByTestId('menu-button'));
expect(screen.getByText('Test Title')).toBeInTheDocument();