Catalog: Add refresh button to AboutCard

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
Johan Haals
2021-09-14 13:18:45 +02:00
parent 75fbb44219
commit dbcaa6387a
17 changed files with 142 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Updates the `AboutCard` with a refresh button that allows the entity to be scheduled for refresh.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/catalog-client': minor
'@backstage/plugin-catalog-react': minor
---
Extends the `CatalogClient` interface with a `refreshEntity` method.
@@ -136,6 +136,24 @@ export class CatalogClient implements CatalogApi {
);
}
async refreshEntity(entityRef: string, options?: CatalogRequestOptions) {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/refresh`,
{
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'POST',
body: JSON.stringify({ entityRef }),
},
);
if (response.status !== 200) {
throw new Error(await response.text());
}
}
async addLocation(
{ type = 'url', target, dryRun, presence }: AddLocationRequest,
options?: CatalogRequestOptions,
+4
View File
@@ -53,6 +53,10 @@ export interface CatalogApi {
uid: string,
options?: CatalogRequestOptions,
): Promise<void>;
refreshEntity(
entityRef: string,
options?: CatalogRequestOptions,
): Promise<void>;
// Locations
getLocationById(
@@ -33,6 +33,7 @@ describe('CatalogIdentityClient', () => {
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
};
const tokenIssuer: jest.Mocked<TokenIssuer> = {
issueToken: jest.fn(),
@@ -68,7 +68,6 @@ beforeEach(() => {
describe('AwsALBAuthProvider', () => {
const catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn(),
removeLocationById: jest.fn(),
getEntities: jest.fn(),
@@ -77,6 +76,7 @@ describe('AwsALBAuthProvider', () => {
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
};
const mockRequest = {
@@ -66,6 +66,7 @@ describe('createRouter', () => {
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
};
config = new ConfigReader({
@@ -104,6 +104,7 @@ describe('CatalogImportClient', () => {
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
};
let catalogImportClient: CatalogImportClient;
@@ -42,6 +42,7 @@ describe('<StepPrepareCreatePullRequest />', () => {
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -109,4 +109,13 @@ export class CatalogClientWrapper implements CatalogApi {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
async refreshEntity(
entityRef: string,
options?: CatalogRequestOptions,
): Promise<void> {
return await this.client.refreshEntity(entityRef, {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
}
@@ -24,13 +24,28 @@ import {
ScmIntegrationsApi,
scmIntegrationsApiRef,
} from '@backstage/integration-react';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import {
catalogApiRef,
EntityProvider,
CatalogApi,
} from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { viewTechDocRouteRef } from '../../routes';
import { AboutCard } from './AboutCard';
describe('<AboutCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
} as any;
it('renders info', async () => {
const entity = {
apiVersion: 'v1',
@@ -62,7 +77,7 @@ describe('<AboutCard />', () => {
integrations: {},
}),
),
);
).with(catalogApiRef, catalogApi);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -109,7 +124,7 @@ describe('<AboutCard />', () => {
},
}),
),
);
).with(catalogApiRef, catalogApi);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -155,7 +170,7 @@ describe('<AboutCard />', () => {
},
}),
),
);
).with(catalogApiRef, catalogApi);
const { getByTitle } = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -188,7 +203,7 @@ describe('<AboutCard />', () => {
const apis = ApiRegistry.with(
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(new ConfigReader({})),
);
).with(catalogApiRef, catalogApi);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -200,6 +215,43 @@ describe('<AboutCard />', () => {
expect(getByText('View Source').closest('a')).not.toHaveAttribute('href');
});
it('triggers a refresh', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const apis = ApiRegistry.with(
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(new ConfigReader({})),
).with(catalogApiRef, catalogApi);
const { getByTitle } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
expect(catalogApi.refreshEntity).not.toHaveBeenCalledWith(
'component:default/software',
);
userEvent.click(getByTitle('Schedule entity refresh'));
expect(catalogApi.refreshEntity).toHaveBeenCalledWith(
'component:default/software',
);
});
it('renders techdocs link', async () => {
const entity = {
apiVersion: 'v1',
@@ -230,7 +282,7 @@ describe('<AboutCard />', () => {
},
}),
),
);
).with(catalogApiRef, catalogApi);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -278,7 +330,7 @@ describe('<AboutCard />', () => {
},
}),
),
);
).with(catalogApiRef, catalogApi);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -321,7 +373,7 @@ describe('<AboutCard />', () => {
},
}),
),
);
).with(catalogApiRef, catalogApi);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -19,6 +19,7 @@ import {
ENTITY_DEFAULT_NAMESPACE,
RELATION_CONSUMES_API,
RELATION_PROVIDES_API,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
HeaderIconLinkRow,
@@ -26,12 +27,13 @@ import {
InfoCardVariants,
Link,
} from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
ScmIntegrationIcon,
scmIntegrationsApiRef,
} from '@backstage/integration-react';
import {
catalogApiRef,
getEntityMetadataEditUrl,
getEntityRelations,
getEntitySourceLocation,
@@ -45,10 +47,11 @@ import {
IconButton,
makeStyles,
} from '@material-ui/core';
import CachedIcon from '@material-ui/icons/Cached';
import DocsIcon from '@material-ui/icons/Description';
import EditIcon from '@material-ui/icons/Edit';
import ExtensionIcon from '@material-ui/icons/Extension';
import React from 'react';
import React, { useCallback } from 'react';
import { viewTechDocRouteRef } from '../../routes';
import { AboutContent } from './AboutContent';
@@ -82,6 +85,8 @@ export function AboutCard({ variant }: AboutCardProps) {
const classes = useStyles();
const { entity } = useEntity();
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
const viewTechdocLink = useRouteRef(viewTechDocRouteRef);
const entitySourceLocation = getEntitySourceLocation(
@@ -142,20 +147,34 @@ export function AboutCard({ variant }: AboutCardProps) {
cardContentClass = classes.fullHeightCardContent;
}
const refreshEntity = useCallback(async () => {
await catalogApi.refreshEntity(stringifyEntityRef(entity));
alertApi.post({ message: 'Refresh scheduled', severity: 'info' });
}, [catalogApi, alertApi, entity]);
return (
<Card className={cardClass}>
<CardHeader
title="About"
action={
<IconButton
component={Link}
aria-label="Edit"
disabled={!entityMetadataEditUrl}
title="Edit Metadata"
to={entityMetadataEditUrl ?? '#'}
>
<EditIcon />
</IconButton>
<>
<IconButton
aria-label="Refresh"
title="Schedule entity refresh"
onClick={refreshEntity}
>
<CachedIcon />
</IconButton>
<IconButton
component={Link}
aria-label="Edit"
disabled={!entityMetadataEditUrl}
title="Edit Metadata"
to={entityMetadataEditUrl ?? '#'}
>
<EditIcon />
</IconButton>
</>
}
subheader={
<HeaderIconLinkRow links={[viewInSource, viewInTechDocs, viewApi]} />
@@ -31,6 +31,7 @@ describe('<DefaultExplorePage />', () => {
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -33,6 +33,7 @@ describe('<DomainExplorerContent />', () => {
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -32,6 +32,7 @@ describe('<GroupsExplorerContent />', () => {
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -32,6 +32,7 @@ describe('<FossaPage />', () => {
getOriginLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
removeLocationById: jest.fn(),
refreshEntity: jest.fn(),
};
const fossaApi: jest.Mocked<FossaApi> = {
getFindingSummary: jest.fn(),
@@ -50,6 +50,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked<CatalogApi> {
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
};
if (entity) {
mock.getEntityByName.mockReturnValue(entity);