Merge pull request #15014 from RoadieHQ/entity-peek-ahead

add entity peek ahead component
This commit is contained in:
Fredrik Adelöw
2023-01-05 14:37:35 +01:00
committed by GitHub
17 changed files with 776 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Add a reusable pop over `EntityPeekAheadPopover` component. It shows more details about the associated entity. See the playbook here https://backstage.io/storybook/?path=/story/catalog-entitypeekaheadpopover--default
+11
View File
@@ -254,6 +254,17 @@ export class EntityOwnerFilter implements EntityFilter {
// @public (undocumented)
export const EntityOwnerPicker: () => JSX.Element | null;
// @public
export const EntityPeekAheadPopover: (
props: EntityPeekAheadPopoverProps,
) => JSX.Element;
// @public
export type EntityPeekAheadPopoverProps = PropsWithChildren<{
entityRef: string;
delayTime?: number;
}>;
// @public (undocumented)
export const EntityProcessingStatusPicker: () => JSX.Element;
+1
View File
@@ -51,6 +51,7 @@
"classnames": "^2.2.6",
"jwt-decode": "^3.1.0",
"lodash": "^4.17.21",
"material-ui-popup-state": "^1.9.3",
"qs": "^6.9.4",
"react-use": "^17.2.4",
"yaml": "^2.0.0",
@@ -0,0 +1,38 @@
/*
* Copyright 2022 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 { IconButton } from '@material-ui/core';
import EmailIcon from '@material-ui/icons/Email';
import React from 'react';
import { Link } from '@backstage/core-components';
/**
* Email Card action link
*
* @private
*/
export const EmailCardAction = ({ email }: { email: string }) => {
return (
<IconButton
component={Link}
aria-label="Email"
title={`Email ${email}`}
to={`mailto:${email}`}
target="_blank"
>
<EmailIcon />
</IconButton>
);
};
@@ -0,0 +1,46 @@
/*
* Copyright 2022 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 { entityRouteRef } from '../../../routes';
import { IconButton } from '@material-ui/core';
import InfoIcon from '@material-ui/icons/Info';
import React from 'react';
import { useRouteRef } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { Link } from '@backstage/core-components';
/**
* Card actions that show for all entities
*
* @private
*/
export const EntityCardActions = ({ entity }: { entity: Entity }) => {
const entityRoute = useRouteRef(entityRouteRef);
return (
<IconButton
component={Link}
aria-label="Show"
title="Show details"
to={entityRoute({
name: entity.metadata.name,
namespace: entity.metadata.namespace || 'default',
kind: entity.kind.toLocaleLowerCase('en-US'),
})}
>
<InfoIcon />
</IconButton>
);
};
@@ -0,0 +1,33 @@
/*
* Copyright 2022 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 { EmailCardAction } from './EmailCardAction';
import React from 'react';
import { GroupEntity } from '@backstage/catalog-model';
/**
* Card actions that show for a group
*
* @private
*/
export const GroupCardActions = ({ entity }: { entity: GroupEntity }) => {
return (
<>
{entity.spec.profile?.email && (
<EmailCardAction email={entity.spec.profile.email} />
)}
</>
);
};
@@ -0,0 +1,33 @@
/*
* Copyright 2022 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 { EmailCardAction } from './EmailCardAction';
import React from 'react';
import { UserEntity } from '@backstage/catalog-model';
/**
* Card actions that show for a user
*
* @private
*/
export const UserCardActions = ({ entity }: { entity: UserEntity }) => {
return (
<>
{entity.spec.profile?.email && (
<EmailCardAction email={entity.spec.profile.email} />
)}
</>
);
};
@@ -0,0 +1,18 @@
/*
* Copyright 2022 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.
*/
export { EntityCardActions } from './EntityCardActions';
export { GroupCardActions } from './GroupCardActions';
export { UserCardActions } from './UserCardActions';
@@ -0,0 +1,197 @@
/*
* Copyright 2022 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, { ComponentType } from 'react';
import {
EntityPeekAheadPopover,
EntityPeekAheadPopoverProps,
} from './EntityPeekAheadPopover';
import Button from '@material-ui/core/Button';
import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils';
import { catalogApiRef } from '../../api';
import {
CompoundEntityRef,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { entityRouteRef } from '../../routes';
import { CatalogApi } from '@backstage/catalog-client';
import { Table, TableColumn } from '@backstage/core-components';
import { EntityRefLink } from '../EntityRefLink';
const mockCatalogApi = {
getEntityByRef: async (entityRef: CompoundEntityRef) => {
if (
entityRef.namespace === 'default' &&
entityRef.name === 'playback' &&
entityRef.kind === 'component'
) {
return {
kind: 'Component',
metadata: {
name: 'playback',
namespace: 'default',
description: 'Details about the playback service',
},
};
}
if (
entityRef.namespace === 'default' &&
entityRef.name === 'fname.lname' &&
entityRef.kind === 'user'
) {
return {
kind: 'User',
metadata: {
name: 'fname.lname',
namespace: 'default',
},
spec: {
profile: {
email: 'fname.lname@example.com',
},
},
};
}
if (
entityRef.namespace === 'default' &&
entityRef.name === 'slow.catalog.item' &&
entityRef.kind === 'component'
) {
await new Promise(resolve => setTimeout(resolve, 3000));
return {
kind: 'Component',
metadata: {
name: 'slow.catalog.item',
namespace: 'default',
description: 'Details about the slow.catalog.item service',
},
};
}
return undefined;
},
};
const defaultArgs = {
entityRef: 'component:default/playback',
};
export default {
title: 'Catalog /PeekAheadPopover',
decorators: [
(Story: ComponentType<{}>) =>
wrapInTestApp(
<>
<TestApiProvider
apis={[[catalogApiRef, mockCatalogApi as any as CatalogApi]]}
>
<Story />
</TestApiProvider>
</>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
),
],
};
export const Default = (args: EntityPeekAheadPopoverProps) => (
<EntityPeekAheadPopover {...args}>
<Button>Hover over me to see details about this component</Button>
</EntityPeekAheadPopover>
);
Default.args = defaultArgs;
export const User = (args: EntityPeekAheadPopoverProps) => (
<EntityPeekAheadPopover {...args}>
<Button>Hover over me to see details about this user</Button>
</EntityPeekAheadPopover>
);
User.args = {
entityRef: 'user:default/fname.lname',
};
export const NotFound = (args: EntityPeekAheadPopoverProps) => (
<EntityPeekAheadPopover {...args}>
<Button>Hover over me to see details about this not found entity</Button>
</EntityPeekAheadPopover>
);
NotFound.args = {
entityRef: 'user:default/doesnt.exist',
};
export const SlowCatalogItem = (args: EntityPeekAheadPopoverProps) => (
<EntityPeekAheadPopover {...args}>
<Button>Hover over me to see details about this slow entity</Button>
</EntityPeekAheadPopover>
);
SlowCatalogItem.args = {
entityRef: 'component:default/slow.catalog.item',
};
const columns: TableColumn<CompoundEntityRef>[] = [
{
title: 'entity',
render: entityRef => {
return (
<EntityPeekAheadPopover entityRef={stringifyEntityRef(entityRef)}>
<EntityRefLink entityRef={entityRef} />
</EntityPeekAheadPopover>
);
},
},
{
title: 'owner',
render: () => {
return (
<EntityPeekAheadPopover entityRef="user:default/fname.lname">
<EntityRefLink
entityRef={parseEntityRef('user:default/fname.lname')}
/>
</EntityPeekAheadPopover>
);
},
},
{
title: 'name',
render: entityRef => stringifyEntityRef(entityRef),
},
];
export const TableOfItems = (args: { data: CompoundEntityRef[] }) => (
<Table columns={columns} data={args.data} />
);
TableOfItems.args = {
data: [
{
name: 'playback',
kind: 'component',
namespace: 'default',
},
{
name: 'playback',
kind: 'component',
namespace: 'default',
},
{
name: 'playback',
kind: 'component',
namespace: 'default',
},
],
};
@@ -0,0 +1,84 @@
/*
* Copyright 2021 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 { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { EntityPeekAheadPopover } from './EntityPeekAheadPopover';
import { ApiProvider } from '@backstage/core-app-api';
import { TestApiRegistry, renderInTestApp } from '@backstage/test-utils';
import { catalogApiRef } from '../../api';
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/catalog-client';
import { Button } from '@material-ui/core';
import { entityRouteRef } from '../../routes';
const catalogApi: Partial<CatalogApi> = {
getEntityByRef: async (
entityRef: CompoundEntityRef,
): Promise<Entity | undefined> => {
if (
entityRef.name === 'service1' &&
entityRef.namespace === 'default' &&
entityRef.kind === 'component'
) {
return {
apiVersion: '',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'service1',
},
spec: {
tags: ['java'],
},
};
}
return undefined;
},
};
const apis = TestApiRegistry.from([catalogApiRef, catalogApi]);
describe('<EntityPeekAheadPopover/>', () => {
it('renders all owners', async () => {
renderInTestApp(
<ApiProvider apis={apis}>
<EntityPeekAheadPopover entityRef="component:default/service1">
<Button data-testid="popover1">s1</Button>
</EntityPeekAheadPopover>
<EntityPeekAheadPopover entityRef="component:default/service2">
<Button data-testid="popover2">s2</Button>
</EntityPeekAheadPopover>
</ApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(screen.getByText('s1')).toBeInTheDocument();
expect(screen.queryByText('service1')).toBeNull();
fireEvent.mouseOver(screen.getByTestId('popover1'));
expect(await screen.findByText('service1')).toBeInTheDocument();
expect(screen.getByText('s2')).toBeInTheDocument();
expect(screen.queryByText('service2')).toBeNull();
fireEvent.mouseOver(screen.getByTestId('popover2'));
expect(
await screen.findByText('Error: service2 was not found'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,208 @@
/*
* Copyright 2022 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 useAsyncFn from 'react-use/lib/useAsyncFn';
import { catalogApiRef } from '../../api';
import React, { PropsWithChildren, useEffect, useState } from 'react';
import HoverPopover from 'material-ui-popup-state/HoverPopover';
import {
bindHover,
bindPopover,
usePopupState,
} from 'material-ui-popup-state/hooks';
import {
Box,
Card,
CardActions,
CardContent,
Chip,
makeStyles,
Tooltip,
Typography,
} from '@material-ui/core';
import { useApiHolder } from '@backstage/core-plugin-api';
import {
isGroupEntity,
isUserEntity,
parseEntityRef,
} from '@backstage/catalog-model';
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import {
EntityCardActions,
UserCardActions,
GroupCardActions,
} from './CardActionComponents';
import { debounce } from 'lodash';
/**
* Properties for an entity popover on hover of a component.
*
* @public
*/
export type EntityPeekAheadPopoverProps = PropsWithChildren<{
entityRef: string;
delayTime?: number;
}>;
const useStyles = makeStyles(() => {
return {
trigger: {
display: 'inline-block',
},
popoverPaper: {
width: '30em',
},
descriptionTypography: {
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
},
};
});
const maxTagChips = 4;
/**
* Shows an entity popover on hover of a component.
*
* @public
*/
export const EntityPeekAheadPopover = (props: EntityPeekAheadPopoverProps) => {
const { entityRef, children, delayTime = 500 } = props;
const classes = useStyles();
const apiHolder = useApiHolder();
const popupState = usePopupState({
variant: 'popover',
popupId: 'entity-peek-ahead',
});
const compoundEntityRef = parseEntityRef(entityRef);
const [isHovered, setIsHovered] = useState(false);
const debouncedHandleMouseEnter = debounce(
() => setIsHovered(true),
delayTime,
);
const [{ loading, error, value: entity }, load] = useAsyncFn(async () => {
const catalogApi = apiHolder.get(catalogApiRef);
if (catalogApi) {
const retrievedEntity = await catalogApi.getEntityByRef(
compoundEntityRef,
);
if (!retrievedEntity) {
throw new Error(`${compoundEntityRef.name} was not found`);
}
return retrievedEntity;
}
return undefined;
}, [apiHolder, compoundEntityRef]);
const handleOnMouseLeave = () => {
setIsHovered(false);
debouncedHandleMouseEnter.cancel();
};
useEffect(() => {
if (popupState.isOpen && !entity && !error && !loading) {
load();
}
}, [popupState.isOpen, load, entity, error, loading]);
return (
<>
<span onMouseEnter={debouncedHandleMouseEnter}>
<span data-testid="trigger" {...bindHover(popupState)}>
{children}
</span>
</span>
{isHovered && (
<HoverPopover
PaperProps={{
className: classes.popoverPaper,
}}
{...bindPopover(popupState)}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
onMouseLeave={handleOnMouseLeave}
>
<>
{error && <ResponseErrorPanel error={error} />}
<Card>
{loading && <Progress />}
<CardContent>
{entity && (
<>
<Typography color="textSecondary">
{compoundEntityRef.namespace}
</Typography>
<Typography variant="h5" component="div">
{compoundEntityRef.name}
</Typography>
<Typography color="textSecondary">{entity.kind}</Typography>
<Typography
className={classes.descriptionTypography}
paragraph
>
{entity.metadata.description}
</Typography>
<Typography>{entity.spec?.type}</Typography>
<Box marginTop="0.5em">
{(entity.metadata.tags || [])
.slice(0, maxTagChips)
.map(tag => {
return <Chip key={tag} size="small" label={tag} />;
})}
{entity.metadata.tags?.length &&
entity.metadata.tags?.length > maxTagChips && (
<Tooltip title="Drill into the entity to see all of the tags.">
<Chip key="other-tags" size="small" label="..." />
</Tooltip>
)}
</Box>
</>
)}
</CardContent>
{!error && (
<CardActions>
{entity && (
<>
{isUserEntity(entity) && (
<UserCardActions entity={entity} />
)}
{isGroupEntity(entity) && (
<GroupCardActions entity={entity} />
)}
<EntityCardActions entity={entity} />
</>
)}
</CardActions>
)}
</Card>
</>
</HoverPopover>
)}
</>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 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.
*/
export * from './EntityPeekAheadPopover';
@@ -0,0 +1,41 @@
/*
* Copyright 2022 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, { ComponentType } from 'react';
import { EntityRefLink, EntityRefLinkProps } from './EntityRefLink';
import { wrapInTestApp } from '@backstage/test-utils';
import { entityRouteRef } from '../../routes';
const defaultArgs = {
entityRef: 'component:default/playback',
};
export default {
title: 'Catalog /EntityRefLink',
decorators: [
(Story: ComponentType<{}>) =>
wrapInTestApp(<Story />, {
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
}),
],
};
export const Default = (args: EntityRefLinkProps) => (
<EntityRefLink {...args} />
);
Default.args = defaultArgs;
@@ -0,0 +1,42 @@
/*
* Copyright 2022 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, { ComponentType } from 'react';
import { EntityRefLinks, EntityRefLinksProps } from './EntityRefLinks';
import { wrapInTestApp } from '@backstage/test-utils';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { entityRouteRef } from '../../routes';
const defaultArgs = {
entityRefs: ['component:default/playback', 'user:default/fname.lname'],
};
export default {
title: 'Catalog /EntityRefLinks',
decorators: [
(Story: ComponentType<{}>) =>
wrapInTestApp(<Story />, {
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
}),
],
};
export const Default = (
args: EntityRefLinksProps<string | CompoundEntityRef>,
) => <EntityRefLinks {...args} />;
Default.args = defaultArgs;
@@ -19,6 +19,7 @@ export * from './EntityKindPicker';
export * from './EntityLifecyclePicker';
export * from './EntityOwnerPicker';
export * from './EntityRefLink';
export * from './EntityPeekAheadPopover';
export * from './EntitySearchBar';
export * from './EntityTable';
export * from './EntityTagPicker';
+1
View File
@@ -12,6 +12,7 @@ const BACKSTAGE_CORE_STORIES = [
'plugins/search-react',
'plugins/home',
'plugins/stack-overflow',
'plugins/catalog-react',
];
// Some configuration needs to be available directly on the exported object
+1
View File
@@ -5477,6 +5477,7 @@ __metadata:
cross-fetch: ^3.1.5
jwt-decode: ^3.1.0
lodash: ^4.17.21
material-ui-popup-state: ^1.9.3
qs: ^6.9.4
react-test-renderer: ^16.13.1
react-use: ^17.2.4