Merge pull request #23401 from backstage/roi/scaffolder-metadata
Standard entity metadata to help measure Scaffolder ROI
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Added a "create something similar" button to the `<AboutCard>` that is visible and links to the scaffolder template corresponding to the entity's `backstage.io/source-template` annotation, if present.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
---
|
||||
|
||||
The `value` sent on the `create` analytics event (fired when a Scaffolder template is executed) is now set to the number of minutes saved by executing the template. This value is derived from the `backstage.io/time-saved` annotation on the template entity, if available.
|
||||
|
||||
Note: the `create` event is now captured in the `<Workflow>` component. If you are directly making use of the alpha-exported `<Stepper>` component, an analytics `create` event will no longer be captured on your behalf.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
The `catalog:write` action now automatically adds a `backstage.io/template-source` annotation, indicating which Scaffolder template was used to create the entity.
|
||||
@@ -659,6 +659,8 @@ metadata:
|
||||
name: v1beta2-demo
|
||||
title: Test Action template
|
||||
description: scaffolder v1beta2 template demo
|
||||
annotations:
|
||||
backstage.io/time-saved: PT4H
|
||||
spec:
|
||||
owner: backstage/techdocs-core
|
||||
type: service
|
||||
@@ -736,6 +738,16 @@ A list of strings that can be associated with the template, e.g.
|
||||
This list will also be used in the frontend to display to the user so you can
|
||||
potentially search and group templates by these tags.
|
||||
|
||||
### `metadata.annotations.[backstage.io/time-saved]` [optional]
|
||||
|
||||
An ISO 8601 duration representing the approximate amount of time saved when
|
||||
someone uses this template (e.g. `PT8H` to mean "8 hours saved" or `PT15M` to
|
||||
mean "15 minutes saved").
|
||||
|
||||
Can be used in combination with the `backstage.io/source-template` annotation,
|
||||
or analytics data, to calculate how much time has been saved through the use
|
||||
of the Scaffolder plugin.
|
||||
|
||||
### `spec.type` [required]
|
||||
|
||||
The type of component created by the template, e.g. `website`. This is used for
|
||||
|
||||
@@ -146,6 +146,25 @@ repository itself. If the URL points to a folder, it is important that it is
|
||||
suffixed with a `'/'` in order for relative path resolution to work
|
||||
consistently.
|
||||
|
||||
### backstage.io/source-template
|
||||
|
||||
```yaml
|
||||
# Example:
|
||||
metadata:
|
||||
annotations:
|
||||
backstage.io/source-template: template:default/create-react-app-template
|
||||
```
|
||||
|
||||
Represents the entity ref of the Scaffolder template that was originally used
|
||||
to create the given entity. Useful to power "create something similar"
|
||||
experiences, as well as to track adherence to software standards across the
|
||||
Catalog.
|
||||
|
||||
Note that this value is only automatically added to an entity when the
|
||||
`catalog:write` action is used to create the `catalog-info.yaml` file. It is
|
||||
otherwise the template author's responsibility to ensure that any entity
|
||||
definition included as part of the template contains this annotation.
|
||||
|
||||
### jenkins.io/job-full-name
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -64,7 +64,7 @@ installed, may be captured.
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `navigate` | The URL of the page that was navigated to. | Fired immediately when route location changes (unless associated plugin/route data is ambiguous, in which case the event is fired after plugin/route data becomes known, immediately before the next event or document unload). The parameters of the current route will be included as attributes. |
|
||||
| `click` | The text of the link that was clicked on. | The `to` attribute represents the URL clicked to. |
|
||||
| `create` | The `name` of the software being created; if no `name` property is requested by the given Software Template, then the string `new {templateName}` is used instead. | The context holds an `entityRef`, set to the template's ref (e.g. `template:default/template-name`). |
|
||||
| `create` | The `name` of the software being created; if no `name` property is requested by the given Software Template, then the string `new {templateName}` is used instead. | The context holds an `entityRef`, set to the template's ref (e.g. `template:default/template-name`). The `value` represents the number of minutes saved by running the template (based on the template's `backstage.io/time-saved` annotation, if available). |
|
||||
| `search` | The search term entered in any search bar component. | The context holds `searchTypes`, representing `types` constraining the search. The `value` represents the total number of search results for the query. This may not be visible if the permission framework is being used. |
|
||||
| `discover` | The title of the search result that was clicked on | The `value` is the result rank. A `to` attribute is also provided. |
|
||||
| `not-found` | The path of the resource that resulted in a not found page | Fired by at least TechDocs. |
|
||||
|
||||
@@ -29,6 +29,7 @@ metadata:
|
||||
title: Alerts
|
||||
icon: alert
|
||||
annotations:
|
||||
backstage.io/source-template: template:default/springboot-template
|
||||
backstage.io/linguist: 'https://github.com/backstage/backstage/tree/master/plugins/playlist'
|
||||
spec:
|
||||
type: service
|
||||
|
||||
@@ -3,6 +3,8 @@ kind: Component
|
||||
metadata:
|
||||
name: www-artist
|
||||
description: Artist main website
|
||||
annotations:
|
||||
backstage.io/source-template: template:default/react-ssr-template
|
||||
spec:
|
||||
type: website
|
||||
lifecycle: production
|
||||
|
||||
@@ -31,7 +31,7 @@ import { AboutCard } from './AboutCard';
|
||||
import { ConfigReader } from '@backstage/core-app-api';
|
||||
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import React from 'react';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
@@ -44,6 +44,7 @@ describe('<AboutCard />', () => {
|
||||
const catalogApi: jest.Mocked<CatalogApi> = {
|
||||
getLocationById: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getEntityByRef: jest.fn(),
|
||||
getEntities: jest.fn(),
|
||||
addLocation: jest.fn(),
|
||||
getLocationByRef: jest.fn(),
|
||||
@@ -266,6 +267,138 @@ describe('<AboutCard />', () => {
|
||||
expect(screen.getByText('View Source').closest('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders "create something similar" button', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'software',
|
||||
annotations: {
|
||||
'backstage.io/source-template': 'template:default/foo-template',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
owner: 'guest',
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
|
||||
// Return any valid value to indicate access to the template is okay.
|
||||
catalogApi.getEntityByRef.mockImplementation(async ref => {
|
||||
expect(ref).toBe('template:default/foo-template');
|
||||
return entity;
|
||||
});
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
scmIntegrationsApiRef,
|
||||
ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{
|
||||
host: 'github.com',
|
||||
token: '...',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
[catalogApiRef, catalogApi],
|
||||
[permissionApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
<AboutCard />
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
'/create/templates/:namespace/:templateName':
|
||||
createFromTemplateRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const createSimilarLink = screen
|
||||
.getByTitle('Create something similar')
|
||||
.closest('a');
|
||||
expect(createSimilarLink).toHaveAttribute(
|
||||
'href',
|
||||
'/create/templates/default/foo-template',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render "create something similar" button if template does not exist', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'software',
|
||||
annotations: {
|
||||
'backstage.io/source-template': 'template:default/gone-template',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
owner: 'guest',
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
|
||||
// Return any valid value to indicate access to the template is okay.
|
||||
catalogApi.getEntityByRef.mockImplementation(async ref => {
|
||||
expect(ref).toBe('template:default/gone-template');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
scmIntegrationsApiRef,
|
||||
ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{
|
||||
host: 'github.com',
|
||||
token: '...',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
[catalogApiRef, catalogApi],
|
||||
[permissionApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
<AboutCard />
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
'/create/templates/:namespace/:templateName':
|
||||
createFromTemplateRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTitle('Create something similar'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'url:https://backstage.io/catalog-info.yaml',
|
||||
'file:../../catalog-info.yaml',
|
||||
|
||||
@@ -27,6 +27,7 @@ import Divider from '@material-ui/core/Divider';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import {
|
||||
AppIcon,
|
||||
HeaderIconLinkRow,
|
||||
IconLinkVerticalProps,
|
||||
InfoCardVariants,
|
||||
@@ -60,6 +61,7 @@ import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
|
||||
import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha';
|
||||
import { useSourceTemplateCompoundEntityRef } from './hooks';
|
||||
|
||||
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
|
||||
|
||||
@@ -108,6 +110,7 @@ export function AboutCard(props: AboutCardProps) {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const viewTechdocLink = useRouteRef(viewTechDocRouteRef);
|
||||
const templateRoute = useRouteRef(createFromTemplateRouteRef);
|
||||
const sourceTemplateRef = useSourceTemplateCompoundEntityRef(entity);
|
||||
const { allowed: canRefresh } = useEntityPermission(
|
||||
catalogEntityRefreshPermission,
|
||||
);
|
||||
@@ -236,6 +239,18 @@ export function AboutCard(props: AboutCardProps) {
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
{sourceTemplateRef && templateRoute && (
|
||||
<IconButton
|
||||
component={Link}
|
||||
title="Create something similar"
|
||||
to={templateRoute({
|
||||
namespace: sourceTemplateRef.namespace,
|
||||
templateName: sourceTemplateRef.name,
|
||||
})}
|
||||
>
|
||||
<AppIcon id="scaffolder" />
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
subheader={<HeaderIconLinkRow links={subHeaderLinks} />}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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 {
|
||||
CompoundEntityRef,
|
||||
Entity,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
// todo: should this be a constant in a scaffolder package?
|
||||
const SOURCE_TEMPLATE_ANNOTATION = 'backstage.io/source-template';
|
||||
|
||||
/**
|
||||
* Returns the compound entity ref of the source template that was used to
|
||||
* create this entity (assuming that it still exists and the user has access
|
||||
* to it).
|
||||
*/
|
||||
export const useSourceTemplateCompoundEntityRef = (entity: Entity) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value: sourceTemplateRef } = useAsync(async () => {
|
||||
const refCandidate =
|
||||
entity.metadata.annotations?.[SOURCE_TEMPLATE_ANNOTATION];
|
||||
let compoundRefCandidate: CompoundEntityRef | undefined;
|
||||
|
||||
if (!refCandidate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for access and that this template still exists.
|
||||
const template = await catalogApi.getEntityByRef(refCandidate);
|
||||
compoundRefCandidate = parseEntityRef(refCandidate);
|
||||
|
||||
return template !== undefined ? compoundRefCandidate : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [catalogApi, entity]);
|
||||
|
||||
return sourceTemplateRef;
|
||||
};
|
||||
@@ -94,4 +94,34 @@ describe('catalog:write', () => {
|
||||
yaml.stringify(entity),
|
||||
);
|
||||
});
|
||||
|
||||
it('should add backstage.io/source-template if provided', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
annotations: {},
|
||||
},
|
||||
spec: {},
|
||||
};
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
templateInfo: { entityRef: 'template:default/test-skeleton' },
|
||||
input: { entity },
|
||||
});
|
||||
|
||||
const expectedEntity = JSON.parse(JSON.stringify(entity));
|
||||
expectedEntity.metadata.annotations = {
|
||||
'backstage.io/source-template': 'template:default/test-skeleton',
|
||||
};
|
||||
|
||||
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(fsMock.writeFile).toHaveBeenCalledWith(
|
||||
resolvePath(mockContext.workspacePath, 'catalog-info.yaml'),
|
||||
yaml.stringify(expectedEntity),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,11 +51,25 @@ export function createCatalogWriteAction() {
|
||||
async handler(ctx) {
|
||||
ctx.logger.info(`Writing catalog-info.yaml`);
|
||||
const { filePath, entity } = ctx.input;
|
||||
const entityRef = ctx.templateInfo?.entityRef;
|
||||
const path = filePath ?? 'catalog-info.yaml';
|
||||
|
||||
await fs.writeFile(
|
||||
resolveSafeChildPath(ctx.workspacePath, path),
|
||||
yaml.stringify(entity),
|
||||
yaml.stringify({
|
||||
...entity,
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
...(entityRef
|
||||
? {
|
||||
annotations: {
|
||||
...entity.metadata.annotations,
|
||||
'backstage.io/source-template': entityRef,
|
||||
},
|
||||
}
|
||||
: undefined),
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,6 +75,11 @@ const useStyles = makeStyles(theme => ({
|
||||
export type StepperProps = {
|
||||
manifest: TemplateParameterSchema;
|
||||
extensions: FieldExtensionOptions<any, any>[];
|
||||
/**
|
||||
* @deprecated This was only ever used for analytics tracking purposes, which
|
||||
* is now handled in the `<Workflow />` component. Passing it in will have no
|
||||
* effect.
|
||||
*/
|
||||
templateName?: string;
|
||||
formProps?: FormProps;
|
||||
initialState?: Record<string, JsonValue>;
|
||||
@@ -147,10 +152,7 @@ export const Stepper = (stepperProps: StepperProps) => {
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
props.onCreate(formState);
|
||||
const name =
|
||||
typeof formState.name === 'string' ? formState.name : undefined;
|
||||
analytics.captureEvent('create', name ?? props.templateName ?? 'unknown');
|
||||
}, [props, formState, analytics]);
|
||||
}, [props, formState]);
|
||||
|
||||
const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts });
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import React from 'react';
|
||||
import { Workflow } from './Workflow';
|
||||
import { analyticsApiRef } from '@backstage/core-plugin-api';
|
||||
import { ScaffolderApi, scaffolderApiRef } from '../../../api';
|
||||
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
|
||||
cancelTask: jest.fn(),
|
||||
@@ -36,10 +37,14 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
|
||||
listActions: jest.fn(),
|
||||
listTasks: jest.fn(),
|
||||
};
|
||||
const catalogApiMock: jest.Mocked<CatalogApi> = {
|
||||
getEntityByRef: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const analyticsMock = new MockAnalyticsApi();
|
||||
const apis = TestApiRegistry.from(
|
||||
[scaffolderApiRef, scaffolderApiMock],
|
||||
[catalogApiRef, catalogApiMock],
|
||||
[analyticsApiRef, analyticsMock],
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Content,
|
||||
InfoCard,
|
||||
@@ -23,12 +23,14 @@ import {
|
||||
} from '@backstage/core-components';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { errorApiRef, useAnalytics, useApi } from '@backstage/core-plugin-api';
|
||||
import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema';
|
||||
import { Stepper, type StepperProps } from '../Stepper/Stepper';
|
||||
import { SecretsContextProvider } from '../../../secrets/SecretsContext';
|
||||
import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties';
|
||||
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
|
||||
import { useTemplateTimeSavedMinutes } from '../../hooks/useTemplateTimeSaved';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
markdown: {
|
||||
@@ -68,9 +70,10 @@ export type WorkflowProps = {
|
||||
* @alpha
|
||||
*/
|
||||
export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
const { title, description, namespace, templateName, ...props } =
|
||||
const { title, description, namespace, templateName, onCreate, ...props } =
|
||||
workflowProps;
|
||||
|
||||
const analytics = useAnalytics();
|
||||
const styles = useStyles();
|
||||
const templateRef = stringifyEntityRef({
|
||||
kind: 'Template',
|
||||
@@ -84,6 +87,21 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
|
||||
const sortedManifest = useFilteredSchemaProperties(manifest);
|
||||
|
||||
const minutesSaved = useTemplateTimeSavedMinutes(templateRef);
|
||||
|
||||
const workflowOnCreate = useCallback(
|
||||
async (formState: Record<string, JsonValue>) => {
|
||||
onCreate(formState);
|
||||
|
||||
const name =
|
||||
typeof formState.name === 'string' ? formState.name : undefined;
|
||||
analytics.captureEvent('create', name ?? templateName ?? 'unknown', {
|
||||
value: minutesSaved,
|
||||
});
|
||||
},
|
||||
[onCreate, analytics, templateName, minutesSaved],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorApi.post(new Error(`Failed to load template, ${error}`));
|
||||
@@ -113,7 +131,7 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => {
|
||||
>
|
||||
<Stepper
|
||||
manifest={sortedManifest}
|
||||
templateName={templateName}
|
||||
onCreate={workflowOnCreate}
|
||||
{...props}
|
||||
/>
|
||||
</InfoCard>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2024 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 { useTemplateTimeSavedMinutes } from './useTemplateTimeSaved';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
|
||||
const getTemplateWithTimeSaved = (
|
||||
timeSaved: string | undefined,
|
||||
): TemplateEntityV1beta3 => ({
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'test-fixture',
|
||||
...(timeSaved
|
||||
? {
|
||||
annotations: {
|
||||
'backstage.io/time-saved': timeSaved,
|
||||
},
|
||||
}
|
||||
: undefined),
|
||||
},
|
||||
spec: {
|
||||
type: 'test-fixture',
|
||||
steps: [],
|
||||
},
|
||||
});
|
||||
|
||||
describe('useTemplateTimeSavedMinutes', () => {
|
||||
it.each([
|
||||
['PT2H', 120],
|
||||
['P3D', 4320],
|
||||
['2 hours', undefined],
|
||||
[undefined, undefined],
|
||||
])(
|
||||
'should return the expected duration given "%s"',
|
||||
async (given, expected) => {
|
||||
const templateRef = 'template:default/happy-path';
|
||||
const template = getTemplateWithTimeSaved(given);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useTemplateTimeSavedMinutes(templateRef),
|
||||
{
|
||||
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
|
||||
<TestApiProvider
|
||||
apis={[[catalogApiRef, { getEntityByRef: async () => template }]]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual(expected);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2024 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { Duration } from 'luxon';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
/**
|
||||
* Returns the backstage.io/time-saved annotation (as a number of minutes) for
|
||||
* a given template entity ref.
|
||||
*/
|
||||
export const useTemplateTimeSavedMinutes = (templateRef: string) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const { value: timeSavedMinutes } = useAsync(async () => {
|
||||
const entity = await catalogApi.getEntityByRef(templateRef);
|
||||
const timeSaved = entity?.metadata.annotations?.['backstage.io/time-saved'];
|
||||
|
||||
// This is not a valid template or the template has no time-saved value.
|
||||
if (!entity || !timeSaved) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const durationMs = Duration.fromISO(timeSaved).as('minutes');
|
||||
|
||||
// The time-saved annotation has an invalid value. Ignore.
|
||||
if (Number.isNaN(durationMs)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return durationMs;
|
||||
}, [catalogApi, templateRef]);
|
||||
|
||||
return timeSavedMinutes;
|
||||
};
|
||||
@@ -29,8 +29,9 @@ import {
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { TemplateWizardPage } from './TemplateWizardPage';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { ANNOTATION_EDIT_URL } from '@backstage/catalog-model';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
return {
|
||||
@@ -59,6 +60,7 @@ const catalogApiMock: jest.Mocked<CatalogApi> = {
|
||||
const analyticsMock = new MockAnalyticsApi();
|
||||
const apis = TestApiRegistry.from(
|
||||
[scaffolderApiRef, scaffolderApiMock],
|
||||
[catalogApiRef, catalogApiMock],
|
||||
[analyticsApiRef, analyticsMock],
|
||||
[catalogApiRef, catalogApiMock],
|
||||
);
|
||||
@@ -70,6 +72,7 @@ const entityRefResponse = {
|
||||
name: 'test',
|
||||
annotations: {
|
||||
[ANNOTATION_EDIT_URL]: 'http://localhost:3000',
|
||||
'backstage.io/time-saved': 'PT2H',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
@@ -138,6 +141,7 @@ describe('TemplateWizardPage', () => {
|
||||
action: 'create',
|
||||
subject: 'expected-name',
|
||||
context: { entityRef: 'template:default/test' },
|
||||
value: 120,
|
||||
});
|
||||
});
|
||||
describe('scaffolder page context menu', () => {
|
||||
|
||||
Reference in New Issue
Block a user