diff --git a/.changeset/catalog-create-something-similar.md b/.changeset/catalog-create-something-similar.md new file mode 100644 index 0000000000..cbd1632d42 --- /dev/null +++ b/.changeset/catalog-create-something-similar.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Added a "create something similar" button to the `` that is visible and links to the scaffolder template corresponding to the entity's `backstage.io/source-template` annotation, if present. diff --git a/.changeset/scaffolder-create-time-saved.md b/.changeset/scaffolder-create-time-saved.md new file mode 100644 index 0000000000..44739bf0f1 --- /dev/null +++ b/.changeset/scaffolder-create-time-saved.md @@ -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 `` component. If you are directly making use of the alpha-exported `` component, an analytics `create` event will no longer be captured on your behalf. diff --git a/.changeset/scaffolder-source-of-truth.md b/.changeset/scaffolder-source-of-truth.md new file mode 100644 index 0000000000..43ea2ff954 --- /dev/null +++ b/.changeset/scaffolder-source-of-truth.md @@ -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. diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index b2089eccaa..038e94bec3 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -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 diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 70f5ff5e14..974fa80993 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -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 diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index a4dfafbf87..6b1f0a8254 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -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. | diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index a3a3780c84..c982d46c3f 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -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 diff --git a/packages/catalog-model/examples/components/www-artist-component.yaml b/packages/catalog-model/examples/components/www-artist-component.yaml index 1ca394ecbc..1c510e876a 100644 --- a/packages/catalog-model/examples/components/www-artist-component.yaml +++ b/packages/catalog-model/examples/components/www-artist-component.yaml @@ -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 diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index e27de38913..f90942dcd8 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -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('', () => { const catalogApi: jest.Mocked = { getLocationById: jest.fn(), getEntityByName: jest.fn(), + getEntityByRef: jest.fn(), getEntities: jest.fn(), addLocation: jest.fn(), getLocationByRef: jest.fn(), @@ -266,6 +267,138 @@ describe('', () => { 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( + + + + + , + { + 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( + + + + + , + { + 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', diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 09e1790f3b..a24a132bc4 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -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) { > + {sourceTemplateRef && templateRoute && ( + + + + )} } subheader={} diff --git a/plugins/catalog/src/components/AboutCard/hooks.ts b/plugins/catalog/src/components/AboutCard/hooks.ts new file mode 100644 index 0000000000..412175ff87 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/hooks.ts @@ -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; +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts index cc22b75da2..658e4365bf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.test.ts @@ -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), + ); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts index 3a11fcd946..27c39dd098 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts @@ -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), + }, + }), ); }, }); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 9b475870d2..d0ce60fd79 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -75,6 +75,11 @@ const useStyles = makeStyles(theme => ({ export type StepperProps = { manifest: TemplateParameterSchema; extensions: FieldExtensionOptions[]; + /** + * @deprecated This was only ever used for analytics tracking purposes, which + * is now handled in the `` component. Passing it in will have no + * effect. + */ templateName?: string; formProps?: FormProps; initialState?: Record; @@ -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 }); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx index 81c5a697e0..27fde2083f 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.test.tsx @@ -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 = { cancelTask: jest.fn(), @@ -36,10 +37,14 @@ const scaffolderApiMock: jest.Mocked = { listActions: jest.fn(), listTasks: jest.fn(), }; +const catalogApiMock: jest.Mocked = { + getEntityByRef: jest.fn(), +} as any; const analyticsMock = new MockAnalyticsApi(); const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], + [catalogApiRef, catalogApiMock], [analyticsApiRef, analyticsMock], ); diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index c6030930c5..e16af5abb9 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -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) => { + 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 => { > diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.test.tsx b/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.test.tsx new file mode 100644 index 0000000000..ed54335c7e --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.test.tsx @@ -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<{}>) => ( + template }]]} + > + {children} + + ), + }, + ); + + await waitFor(() => { + expect(result.current).toEqual(expected); + }); + }, + ); +}); diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.ts b/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.ts new file mode 100644 index 0000000000..df87a8bdfa --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateTimeSaved.ts @@ -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; +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx index 5cdae85e8a..269ff81271 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -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 = { 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', () => {