diff --git a/.changeset/itchy-kiwis-unite.md b/.changeset/itchy-kiwis-unite.md new file mode 100644 index 0000000000..0e3bcae282 --- /dev/null +++ b/.changeset/itchy-kiwis-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-explore': patch +--- + +Extracted generic `CatalogKindExploreContent` component so that it is easy to show any component kinds in their own tab in the explore page. diff --git a/plugins/explore/README.md b/plugins/explore/README.md index 0bb7258ae6..a1cb273aab 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -2,7 +2,7 @@ Welcome to the explore plugin! -This plugin helps to visualize the domains and tools in your ecosystem. +This plugin helps to visualize the top level entities like domains, groups and tools in your ecosystem. ## Setup @@ -117,7 +117,10 @@ export const ExplorePage = () => { subtitle="Browse our ecosystem" > - + + + + diff --git a/plugins/explore/api-report.md b/plugins/explore/api-report.md index db89db6503..70ad479841 100644 --- a/plugins/explore/api-report.md +++ b/plugins/explore/api-report.md @@ -32,6 +32,12 @@ export const catalogEntityRouteRef: ExternalRouteRef< true >; +// @public (undocumented) +export const CatalogKindExploreContent: (props: { + title?: string; + kind: string; +}) => JSX.Element; + // @public (undocumented) export const DomainCard: (props: { entity: DomainEntity }) => JSX.Element; diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 3b642e1098..8b1d978836 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -47,6 +47,7 @@ "@material-ui/lab": "4.0.0-alpha.61", "@types/react": "^16.13.1 || ^17.0.0", "classnames": "^2.2.6", + "pluralize": "^8.0.0", "react-use": "^17.2.4" }, "peerDependencies": { diff --git a/plugins/explore/src/components/CatalogKindExploreContent/CatalogKindExploreContent.test.tsx b/plugins/explore/src/components/CatalogKindExploreContent/CatalogKindExploreContent.test.tsx new file mode 100644 index 0000000000..4f4f0f034c --- /dev/null +++ b/plugins/explore/src/components/CatalogKindExploreContent/CatalogKindExploreContent.test.tsx @@ -0,0 +1,136 @@ +/* + * Copyright 2020 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 { DomainEntity } from '@backstage/catalog-model'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { CatalogKindExploreContent } from './CatalogKindExploreContent'; + +describe('', () => { + const catalogApi = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getLocationByRef: jest.fn(), + getLocationById: jest.fn(), + removeLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByRef: jest.fn(), + refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), + validateEntity: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + const mountedRoutes = { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders a grid of domains', async () => { + const entities: DomainEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'playback', + }, + spec: { + owner: 'guest', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'artists', + }, + spec: { + owner: 'guest', + }, + }, + ]; + catalogApi.getEntities.mockResolvedValue({ items: entities }); + + const { getByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + await waitFor(() => { + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('playback')).toBeInTheDocument(); + }); + }); + + it('renders a custom title', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + await waitFor(() => expect(getByText('Our Areas')).toBeInTheDocument()); + }); + + it('renders empty state', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + await waitFor(() => + expect(getByText('No domains to display')).toBeInTheDocument(), + ); + }); + + it('renders a friendly error if it cannot collect domains', async () => { + const catalogError = new Error('Network timeout'); + catalogApi.getEntities.mockRejectedValueOnce(catalogError); + + const { getByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + await waitFor(() => + expect(getByText(/Could not load domains/)).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/CatalogKindExploreContent/CatalogKindExploreContent.tsx b/plugins/explore/src/components/CatalogKindExploreContent/CatalogKindExploreContent.tsx new file mode 100644 index 0000000000..f6f76ce1e0 --- /dev/null +++ b/plugins/explore/src/components/CatalogKindExploreContent/CatalogKindExploreContent.tsx @@ -0,0 +1,108 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Button } from '@material-ui/core'; +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { EntityCard } from '../EntityCard'; +import { + Content, + ContentHeader, + EmptyState, + ItemCardGrid, + Progress, + SupportButton, + WarningPanel, +} from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import pluralize from 'pluralize'; + +const Body = (props: { kind: string }) => { + const { kind } = props; + const kindPlural = pluralize(kind); + const catalogApi = useApi(catalogApiRef); + const { + value: entities, + loading, + error, + } = useAsync(async () => { + const response = await catalogApi.getEntities({ + filter: { kind }, + }); + return response.items as Entity[]; + }, [kind]); + + if (loading) { + return ; + } + + if (error) { + return ( + + {error.message} + + ); + } + + if (!entities?.length) { + return ( + + Read more + + } + /> + ); + } + + return ( + + {entities.map((entity, index) => ( + + ))} + + ); +}; + +/** @public */ +export const CatalogKindExploreContent = (props: { + title?: string; + kind: string; +}) => { + const { kind, title } = props; + const kindLowercase = kind.toLocaleLowerCase(); + const kindCapitalized = `${kind[0].toLocaleUpperCase()}${kind.substring(1)}`; + return ( + + + + Discover the {pluralize(kindLowercase)} in your ecosystem. + + + + + ); +}; diff --git a/plugins/explore/src/components/CatalogKindExploreContent/index.ts b/plugins/explore/src/components/CatalogKindExploreContent/index.ts new file mode 100644 index 0000000000..af4cafae62 --- /dev/null +++ b/plugins/explore/src/components/CatalogKindExploreContent/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { CatalogKindExploreContent } from './CatalogKindExploreContent'; diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx index 54beb59619..098f73be2e 100644 --- a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx @@ -15,11 +15,11 @@ */ import React from 'react'; -import { DomainExplorerContent } from '../DomainExplorerContent'; import { ExploreLayout } from '../ExploreLayout'; import { GroupsExplorerContent } from '../GroupsExplorerContent'; import { ToolExplorerContent } from '../ToolExplorerContent'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { CatalogKindExploreContent } from '../CatalogKindExploreContent'; export const DefaultExplorePage = () => { const configApi = useApi(configApiRef); @@ -32,7 +32,7 @@ export const DefaultExplorePage = () => { subtitle="Discover solutions available in your ecosystem" > - + diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx index 3cfed5b12b..15c0d650cc 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx @@ -19,7 +19,6 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Button } from '@material-ui/core'; import React from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { DomainCard } from '../DomainCard'; import { Content, ContentHeader, @@ -30,6 +29,7 @@ import { WarningPanel, } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; +import { EntityCard } from '../EntityCard'; const Body = () => { const catalogApi = useApi(catalogApiRef); @@ -78,7 +78,7 @@ const Body = () => { return ( {entities.map((entity, index) => ( - + ))} ); diff --git a/plugins/explore/src/components/EntityCard/EntityCard.test.tsx b/plugins/explore/src/components/EntityCard/EntityCard.test.tsx new file mode 100644 index 0000000000..4b006e347f --- /dev/null +++ b/plugins/explore/src/components/EntityCard/EntityCard.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2020 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 { Entity } from '@backstage/catalog-model'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { EntityCard } from './EntityCard'; + +describe('', () => { + it('renders an entity card', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'artists', + description: 'Everything about artists', + tags: ['a-tag'], + }, + spec: { + owner: 'guest', + }, + }; + const { getByText } = await renderInTestApp( + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('Everything about artists')).toBeInTheDocument(); + expect(getByText('a-tag')).toBeInTheDocument(); + expect(getByText('Explore').parentElement).toHaveAttribute( + 'href', + '/catalog/default/domain/artists', + ); + }); +}); diff --git a/plugins/explore/src/components/EntityCard/EntityCard.tsx b/plugins/explore/src/components/EntityCard/EntityCard.tsx new file mode 100644 index 0000000000..0a5908df38 --- /dev/null +++ b/plugins/explore/src/components/EntityCard/EntityCard.tsx @@ -0,0 +1,75 @@ +/* + * 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 { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + EntityRefLinks, + entityRouteParams, + getEntityRelations, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { + Box, + Card, + CardActions, + CardContent, + CardMedia, + Chip, +} from '@material-ui/core'; +import React from 'react'; + +import { LinkButton, ItemCardHeader } from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; + +/** @public */ +export const EntityCard = (props: { entity: Entity }) => { + const { entity } = props; + + const catalogEntityRoute = useRouteRef(entityRouteRef); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + const url = catalogEntityRoute(entityRouteParams(entity)); + + const owner = ( + + ); + + return ( + + + + + + {entity.metadata.tags?.length ? ( + + {entity.metadata.tags.map(tag => ( + + ))} + + ) : null} + {entity.metadata.description} + + + + Explore + + + + ); +}; diff --git a/plugins/explore/src/components/EntityCard/index.ts b/plugins/explore/src/components/EntityCard/index.ts new file mode 100644 index 0000000000..be80fb3f8f --- /dev/null +++ b/plugins/explore/src/components/EntityCard/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { EntityCard } from './EntityCard'; diff --git a/plugins/explore/src/components/index.ts b/plugins/explore/src/components/index.ts index f8fba16910..b3aa82bc79 100644 --- a/plugins/explore/src/components/index.ts +++ b/plugins/explore/src/components/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ -export * from './DomainCard'; +export * from './CatalogKindExploreContent'; export * from './ExploreLayout'; +export * from './DomainCard'; diff --git a/yarn.lock b/yarn.lock index 367a00c90e..f0b6f7c25b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6374,6 +6374,7 @@ __metadata: classnames: ^2.2.6 cross-fetch: ^3.1.5 msw: ^1.0.0 + pluralize: ^8.0.0 react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0