refactor: keep only the ability of overriding a defautl header for now

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2025-02-28 12:13:43 +01:00
parent 247a40b9a5
commit 87baa188dd
9 changed files with 50 additions and 324 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/plugin-catalog': minor
---
Now multiple entity page headers can be configured based on an entity filter.
Now a custom entity page header can be passed as input to the default entity page.
+1 -81
View File
@@ -2,52 +2,7 @@
'@backstage/plugin-catalog-react': minor
---
Introduce a new `EntityHeaderBlueprint` that allows you to customize the default header and also have different headers depending on an entity filter.
### Usage examples
Customizing the default header to render more title actions:
```jsx
import { EntityHeaderBlueprint } from '@backstage/plugin-catalog-react/alpha';
// ...
function CopyEntityNameToClipboard() {
const { entity } = useEntity();
const alertApi = useApi(alertApiRef);
const handleClick = useCallback(() => {
if (!entity) return;
window.navigator.clipboard
.writeText(entity.metadata.name)
.then(() =>
alertApi.post({ message: 'Entity name copied to clipboard!' }),
);
}, [entity, alertApi]);
return (
<Tooltip title="Copy to clipboard">
<IconButton onClick={handleClick}>
<FileCopyIcon htmlColor="#fff" />
</IconButton>
</Tooltip>
);
}
EntityHeaderBlueprint.make({
name: 'my-default-header',
params: {
// The `FavoriteEntity` icon button is added by default
// You can also completely override the default title
// title: <MyTitleComponent />
title: { actions: [<CopyEntityNameToClipboard />] },
// A subtitle element is also supported.
// subtitle: <MySubtitleComponent />
},
});
```
Setting up a completely different default header component:
Introduces a new `EntityHeaderBlueprint` that allows you to override the default entity page header.
```jsx
import { EntityHeaderBlueprint } from '@backstage/plugin-catalog-react/alpha';
@@ -59,38 +14,3 @@ EntityHeaderBlueprint.make({
},
});
```
Use a different header for entities of type template:
```jsx
import { EntityHeaderBlueprint } from '@backstage/plugin-catalog-react/alpha';
import { MyTemplateHeader } from './MyTemplateHeader';
EntityHeaderBlueprint.make({
name: 'my-template-header',
params: {
defaultFilter: 'kind:template',
loader: () => import('./MyTemplateHeader').then(m => <m.MyTemplateHeader />),
});
```
Disabling a header via configuration:
```yaml
# app-config.yaml
app:
extensions:
- entity-header:app/my-template-header: false
```
Changing a header default filter via configuration:
```yaml
# app-config.yaml
app:
extensions:
- entity-header:app/my-template-header:
config:
# Using this custom header with components instead
filter: 'kind:component'
```
+1 -1
View File
@@ -23,7 +23,7 @@ app:
# - development: false
# example overriding a default group title
- documentation:
title: Docs 2
title: Docs
- deployment:
title: Deployments
# example adding a new group
+12 -83
View File
@@ -14,7 +14,6 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -326,95 +325,25 @@ export interface EntityContentLayoutProps {
export const EntityHeaderBlueprint: ExtensionBlueprint<{
kind: 'entity-header';
name: undefined;
params:
| {
defaultFilter?: string | ((entity: Entity) => boolean) | undefined;
loader: () => Promise<JSX.Element>;
}
| {
defaultFilter?: string | ((entity: Entity) => boolean) | undefined;
title?:
| ReactNode
| {
actions: ReactNode[];
};
subtitle?: ReactNode;
};
output:
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
| ReactNode
| {
actions: ReactNode[];
},
'entity-header.titleActions',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
ReactNode,
'entity-header.subtitle',
{
optional: true;
}
>;
params: {
loader: () => Promise<JSX.Element>;
};
output: ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{
optional: true;
}
>;
inputs: {};
config: {
filter: string | undefined;
};
configInput: {
filter?: string | undefined;
};
config: {};
configInput: {};
dataRefs: {
filterFunction: ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{}
>;
filterExpression: ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{}
>;
element: ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{}
>;
title: ConfigurableExtensionDataRef<
| ReactNode
| {
actions: ReactNode[];
},
'entity-header.titleActions',
{}
>;
subtitle: ConfigurableExtensionDataRef<
ReactNode,
'entity-header.subtitle',
{}
>;
};
}>;
@@ -14,88 +14,31 @@
* limitations under the License.
*/
import { ReactNode } from 'react';
import {
createExtensionBlueprint,
coreExtensionData,
createExtensionDataRef,
ExtensionBoundary,
} from '@backstage/frontend-plugin-api';
import {
entityFilterExpressionDataRef,
entityFilterFunctionDataRef,
} from './extensionData';
const entityHeaderTitleActionsDataRef = createExtensionDataRef<
ReactNode | { actions: ReactNode[] }
>().with({ id: 'entity-header.titleActions' });
const entityHeaderSubtitleDataRef = createExtensionDataRef<ReactNode>().with({
id: 'entity-header.subtitle',
});
/** @alpha */
export const EntityHeaderBlueprint = createExtensionBlueprint({
kind: 'entity-header',
attachTo: { id: 'page:catalog/entity', input: 'headers' },
output: [
entityFilterFunctionDataRef.optional(),
entityFilterExpressionDataRef.optional(),
coreExtensionData.reactElement.optional(),
entityHeaderTitleActionsDataRef.optional(),
entityHeaderSubtitleDataRef.optional(),
],
attachTo: { id: 'page:catalog/entity', input: 'header' },
dataRefs: {
filterFunction: entityFilterFunctionDataRef,
filterExpression: entityFilterExpressionDataRef,
element: coreExtensionData.reactElement,
title: entityHeaderTitleActionsDataRef,
subtitle: entityHeaderSubtitleDataRef,
},
config: {
schema: {
filter: z => z.string().optional(),
},
},
output: [coreExtensionData.reactElement.optional()],
*factory(
params:
| {
defaultFilter?:
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
loader: () => Promise<JSX.Element>;
}
| {
defaultFilter?:
| typeof entityFilterFunctionDataRef.T
| typeof entityFilterExpressionDataRef.T;
title?: ReactNode | { actions: ReactNode[] };
subtitle?: ReactNode;
},
{ config, node },
params: {
loader: () => Promise<JSX.Element>;
},
{ node },
) {
const { defaultFilter } = params;
if (config.filter) {
yield entityFilterExpressionDataRef(config.filter);
} else if (typeof defaultFilter === 'string') {
yield entityFilterExpressionDataRef(defaultFilter);
} else if (typeof defaultFilter === 'function') {
yield entityFilterFunctionDataRef(defaultFilter);
}
if ('loader' in params) {
const { loader } = params;
if (loader) {
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, params.loader),
ExtensionBoundary.lazy(node, loader),
);
}
if ('title' in params && params.title) {
yield entityHeaderTitleActionsDataRef(params.title);
}
if ('subtitle' in params && params.subtitle) {
yield entityHeaderSubtitleDataRef(params.subtitle);
}
},
});
+10 -42
View File
@@ -20,7 +20,6 @@ import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha';
import { SearchResultItemExtensionPredicate } from '@backstage/plugin-search-react/alpha';
@@ -973,48 +972,17 @@ const _default: FrontendPlugin<
}
>;
inputs: {
headers: ExtensionInput<
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
| ReactNode
| {
actions: ReactNode[];
},
'entity-header.titleActions',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
ReactNode,
'entity-header.subtitle',
{
optional: true;
}
>,
header: ExtensionInput<
ConfigurableExtensionDataRef<
JSX_2.Element,
'core.reactElement',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
singleton: true;
optional: true;
}
>;
contents: ExtensionInput<
@@ -20,7 +20,6 @@ import React, {
useEffect,
ComponentProps,
ReactNode,
Fragment,
} from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import useAsync from 'react-use/esm/useAsync';
@@ -98,12 +97,6 @@ function findParentRelation(
return null;
}
function isDefaultHeaderProps(
title: ReactNode | { actions: ReactNode[] },
): title is { actions: ReactNode[] } {
return title !== null && typeof title === 'object' && 'actions' in title;
}
const useStyles = makeStyles(theme => ({
breadcrumbs: {
color: theme.page.fontColor,
@@ -119,8 +112,7 @@ const useStyles = makeStyles(theme => ({
},
}));
function EntityHeaderTitle(props: { actions?: ReactNode[] }) {
const { actions } = props;
function EntityHeaderTitle() {
const { entity } = useAsyncEntity();
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const { headerTitle: title } = headerProps(kind, namespace, name, entity);
@@ -135,9 +127,6 @@ function EntityHeaderTitle(props: { actions?: ReactNode[] }) {
{entity ? <EntityDisplayName entityRef={entity} hideIcon /> : title}
</Box>
{entity && <FavoriteEntity entity={entity} />}
{actions?.map((action, index) => (
<Fragment key={index}>{action}</Fragment>
))}
</Box>
);
}
@@ -200,14 +189,14 @@ export function EntityHeader(props: {
* It adds breadcrumbs in the Entity page to enhance user navigation and context awareness.
*/
parentEntityRelations?: string[];
title?: ReactNode | { actions: ReactNode[] };
title?: ReactNode;
subtitle?: ReactNode;
}) {
const {
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
parentEntityRelations,
title = { actions: [] },
title,
subtitle,
} = props;
const { entity } = useAsyncEntity();
@@ -236,7 +225,7 @@ export function EntityHeader(props: {
[setConfirmationDialogOpen],
);
const cleanUpAfterUnregiterConfirmation = useCallback(async () => {
const cleanUpAfterUnregisterConfirmation = useCallback(async () => {
setConfirmationDialogOpen(false);
navigate(
unregisterRedirectRoute ? unregisterRedirectRoute() : catalogRoute(),
@@ -279,9 +268,7 @@ export function EntityHeader(props: {
<Header
pageTitleOverride={entityFallbackText}
type={type}
title={
isDefaultHeaderProps(title) ? <EntityHeaderTitle {...title} /> : title
}
title={title ?? <EntityHeaderTitle />}
subtitle={
subtitle ?? (
<EntityHeaderSubtitle parentEntityRelations={parentEntityRelations} />
@@ -312,7 +299,7 @@ export function EntityHeader(props: {
entity={entity!}
open={confirmationDialogOpen}
onClose={closeUnregisterEntityDialog}
onConfirm={cleanUpAfterUnregiterConfirmation}
onConfirm={cleanUpAfterUnregisterConfirmation}
/>
</>
)}
-1
View File
@@ -117,7 +117,6 @@ describe('Entity page', () => {
params: {
defaultPath: '/overview',
defaultTitle: 'Overview',
defaultGroup: 'documentation',
loader: async () => <div>Mock Overview content</div>,
},
});
+10 -30
View File
@@ -60,13 +60,10 @@ export const catalogPage = PageBlueprint.makeWithOverrides({
export const catalogEntityPage = PageBlueprint.makeWithOverrides({
name: 'entity',
inputs: {
headers: createExtensionInput([
EntityHeaderBlueprint.dataRefs.filterFunction.optional(),
EntityHeaderBlueprint.dataRefs.filterExpression.optional(),
EntityHeaderBlueprint.dataRefs.element.optional(),
EntityHeaderBlueprint.dataRefs.title.optional(),
EntityHeaderBlueprint.dataRefs.subtitle.optional(),
]),
header: createExtensionInput(
[EntityHeaderBlueprint.dataRefs.element.optional()],
{ singleton: true, optional: true },
),
contents: createExtensionInput([
coreExtensionData.reactElement,
coreExtensionData.routePath,
@@ -90,24 +87,6 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
loader: async () => {
const headers = inputs.headers.map(header => {
const element = header.get(
EntityHeaderBlueprint.dataRefs.element,
) ?? (
<EntityHeader
title={header.get(EntityHeaderBlueprint.dataRefs.title)}
subtitle={header.get(EntityHeaderBlueprint.dataRefs.subtitle)}
/>
);
return {
filter: buildFilterFn(
header.get(EntityHeaderBlueprint.dataRefs.filterFunction),
header.get(EntityHeaderBlueprint.dataRefs.filterExpression),
),
element,
};
});
const { EntityLayout } = await import('./components/EntityLayout');
type Groups = Record<
@@ -115,6 +94,10 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
{ title: string; items: Array<(typeof inputs.contents)[0]> }
>;
const header = inputs.header?.get(
EntityHeaderBlueprint.dataRefs.element,
) ?? <EntityHeader />;
let groups = Object.entries(defaultEntityContentGroups).reduce<Groups>(
(rest, group) => {
const [groupId, groupValue] = group;
@@ -150,12 +133,9 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
}
const Component = () => {
const { entity, ...rest } = useEntityFromUrl();
const header = headers.find(({ filter }) => entity && filter(entity));
return (
<AsyncEntityProvider {...rest} entity={entity}>
<EntityLayout header={header?.element}>
<AsyncEntityProvider {...useEntityFromUrl()}>
<EntityLayout header={header}>
{Object.values(groups).flatMap(({ title, items }) =>
items.map(output => (
<EntityLayout.Route