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:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -117,7 +117,6 @@ describe('Entity page', () => {
|
||||
params: {
|
||||
defaultPath: '/overview',
|
||||
defaultTitle: 'Overview',
|
||||
defaultGroup: 'documentation',
|
||||
loader: async () => <div>Mock Overview content</div>,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user